Files
please-pay-me/mobile/lib/features/auth/yandex_login.dart
T
2026-09-21 04:06:43 +03:00

333 lines
9.5 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:please_pay_me/data/api/api_client.dart';
import 'package:please_pay_me/data/models/auth_user.dart';
import 'package:please_pay_me/data/models/json.dart';
import 'package:please_pay_me/features/auth/telegram_login.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:please_pay_me/ui/ui.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
const yandexAuthorizeHost = 'oauth.yandex.ru';
/// Same platforms as Telegram WebView login.
bool get isYandexWebLoginSupported => isTelegramWebLoginSupported;
class YandexAuthProvider {
const YandexAuthProvider({
required this.enabled,
required this.clientId,
this.redirectUri,
});
final bool enabled;
final String clientId;
final String? redirectUri;
bool get usable => enabled && clientId.isNotEmpty;
factory YandexAuthProvider.fromJson(Map<String, dynamic> json) {
return YandexAuthProvider(
enabled: asBool(json['enabled']),
clientId: asString(json['client_id']),
redirectUri: asStringOrNull(json['redirect_uri']),
);
}
}
class AuthProviders {
const AuthProviders({this.yandex});
final YandexAuthProvider? yandex;
factory AuthProviders.fromJson(Map<String, dynamic> json) {
final raw = json['yandex'];
if (raw is! Map) return const AuthProviders();
return AuthProviders(yandex: YandexAuthProvider.fromJson(asMap(raw)));
}
}
class YandexOAuthCallback {
const YandexOAuthCallback({this.code, this.error});
final String? code;
final String? error;
}
Uri yandexAuthorizeUri({
required String clientId,
required String redirectUri,
}) {
return Uri.https(yandexAuthorizeHost, '/authorize', {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'force_confirm': 'yes',
});
}
/// Callback registered in the Yandex app: origin + trailing slash.
String cabinetYandexRedirectUri(String cabinetUrl, {String? configured}) {
if (configured != null && configured.isNotEmpty) {
final parsed = Uri.tryParse(configured);
if (parsed != null && parsed.host.toLowerCase() == Uri.parse(resolveCabinetOrigin(cabinetUrl)).host.toLowerCase()) {
return configured;
}
}
return '${resolveCabinetOrigin(cabinetUrl)}/';
}
/// @deprecated use [cabinetYandexRedirectUri]
String cabinetLoginRedirectUri(String cabinetUrl) => cabinetYandexRedirectUri(cabinetUrl);
/// True when [uri] is the registered cabinet callback (code or error).
YandexOAuthCallback? parseYandexCallback(Uri uri, {required String redirectUri}) {
final expected = Uri.tryParse(redirectUri);
if (expected == null) return null;
if (uri.host.toLowerCase() != expected.host.toLowerCase()) return null;
final expectedPath = expected.path.isEmpty ? '/' : expected.path;
if (_normalizePath(uri.path) != _normalizePath(expectedPath)) return null;
final code = uri.queryParameters['code']?.trim();
final error = uri.queryParameters['error_description']?.trim() ??
uri.queryParameters['error']?.trim();
if ((code == null || code.isEmpty) && (error == null || error.isEmpty)) {
return null;
}
return YandexOAuthCallback(
code: code == null || code.isEmpty ? null : code,
error: error == null || error.isEmpty ? null : error,
);
}
String _normalizePath(String path) {
if (path.isEmpty) return '/';
return path.length > 1 && path.endsWith('/') ? path.substring(0, path.length - 1) : path;
}
Future<AuthProviders> fetchAuthProviders(
String apiBaseUrl, {
http.Client? httpClient,
}) async {
final client = ApiClient(
baseUrl: apiBaseUrl,
tokenProvider: () => null,
httpClient: httpClient,
);
return AuthProviders.fromJson(await client.getJson('/api/auth/providers'));
}
Future<String> exchangeYandexCode({
required String apiBaseUrl,
required String code,
required String redirectUri,
http.Client? httpClient,
}) async {
final client = ApiClient(
baseUrl: apiBaseUrl,
tokenProvider: () => null,
httpClient: httpClient,
);
final session = AuthSession.fromJson(
await client.postJson(
'/api/auth/yandex',
body: {'code': code, 'redirect_uri': redirectUri},
),
);
return session.accessToken;
}
/// Opens Yandex OAuth in a WebView, intercepts the cabinet callback, exchanges
/// the code on the API and returns a JWT.
Future<String?> showYandexLogin({
required BuildContext context,
required String clientId,
required String redirectUri,
required Future<String> Function(String code) exchangeCode,
}) {
return Navigator.of(context, rootNavigator: true).push<String>(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (_) => YandexLoginScreen(
clientId: clientId,
redirectUri: redirectUri,
exchangeCode: exchangeCode,
),
),
);
}
class YandexLoginScreen extends StatefulWidget {
const YandexLoginScreen({
super.key,
required this.clientId,
required this.redirectUri,
required this.exchangeCode,
});
final String clientId;
final String redirectUri;
final Future<String> Function(String code) exchangeCode;
@override
State<YandexLoginScreen> createState() => _YandexLoginScreenState();
}
class _YandexLoginScreenState extends State<YandexLoginScreen> {
late final WebViewController _controller;
bool _loading = true;
bool _completed = false;
String? _error;
Uri get _startUri => yandexAuthorizeUri(
clientId: widget.clientId,
redirectUri: widget.redirectUri,
);
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(telegramWebViewUserAgent)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: _onNavigationRequest,
onPageStarted: (url) {
if (mounted) setState(() => _loading = true);
_tryFinish(url);
},
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
onWebResourceError: (error) {
if (!mounted || _completed) return;
if (error.isForMainFrame == false) return;
setState(() {
_loading = false;
_error = error.description;
});
},
),
);
_configureAndroid();
_controller.loadRequest(_startUri);
}
Future<void> _configureAndroid() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
final platform = _controller.platform;
if (platform is! AndroidWebViewController) return;
final cookies = AndroidWebViewCookieManager(
const PlatformWebViewCookieManagerCreationParams(),
);
await cookies.setAcceptThirdPartyCookies(platform, true);
}
Future<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
if (await _tryFinish(request.url)) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
Future<bool> _tryFinish(String url) async {
if (_completed) return true;
final uri = Uri.tryParse(url);
if (uri == null) return false;
final callback = parseYandexCallback(uri, redirectUri: widget.redirectUri);
if (callback == null) return false;
if (callback.error != null) {
_completed = true;
if (mounted) {
setState(() {
_loading = false;
_error = callback.error;
});
}
return true;
}
final code = callback.code;
if (code == null) return false;
_completed = true;
if (mounted) setState(() => _loading = true);
try {
final token = await widget.exchangeCode(code);
if (!mounted) return true;
Navigator.of(context).pop(token);
} on ApiException catch (error) {
if (!mounted) return true;
setState(() {
_loading = false;
_error = error.message;
_completed = false;
});
}
return true;
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
navigationBar: AppNavBar(
title: 'Вход через Яндекс',
subtitle: yandexAuthorizeHost,
leading: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => Navigator.of(context).pop(),
child: const AppText.body('Закрыть', color: AppColors.accent),
),
trailing: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
setState(() {
_error = null;
_completed = false;
});
_controller.loadRequest(_startUri);
},
child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent),
),
),
child: SafeArea(
child: _error != null
? AppErrorView(
message: _error!,
onRetry: () {
setState(() {
_error = null;
_completed = false;
});
_controller.loadRequest(_startUri);
},
)
: Stack(
children: [
WebViewWidget(controller: _controller),
if (_loading) const Center(child: AppSpinner()),
],
),
),
);
}
}