diff --git a/lib/core/cubit/base_cubit.dart b/lib/core/cubit/base_cubit.dart index 3cd6c9d9..03ff3cc2 100644 --- a/lib/core/cubit/base_cubit.dart +++ b/lib/core/cubit/base_cubit.dart @@ -7,7 +7,7 @@ abstract class BaseCubit extends Cubit { /// Унифицированная обработка ошибок void handleError(Object error, [StackTrace? stackTrace]) { - log('Error in ${runtimeType}: $error', stackTrace: stackTrace); + log('Error in $runtimeType: $error', stackTrace: stackTrace); // Здесь можно добавить отправку ошибок в Sentry или другую систему мониторинга } diff --git a/lib/features/auth/entities/auth_result_entity.dart b/lib/features/auth/entities/auth_result_entity.dart new file mode 100644 index 00000000..2f296e5b --- /dev/null +++ b/lib/features/auth/entities/auth_result_entity.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:vk_id/vk_id.dart'; + +part 'auth_result_entity.freezed.dart'; + +@freezed +abstract class AuthResultEntity with _$AuthResultEntity { + const factory AuthResultEntity({ + required String accessToken, + required String userId, + String? refreshToken, + String? firstName, + String? lastName, + String? email, + String? phone, + GenderEnum? gender, + String? birthDate, + }) = _AuthResultEntity; +} + +enum GenderEnum { + male, + female, + notSet; + + static GenderEnum? fromVkGender(VkSex? vkGender) { + switch (vkGender) { + case VkSex.male: + return GenderEnum.male; + case VkSex.female: + return GenderEnum.female; + case VkSex.notStated: + return GenderEnum.notSet; + case null: + return null; + } + } + + static GenderEnum? fromString(String? gender) { + switch (gender) { + case 'male': + return GenderEnum.male; + case 'female': + return GenderEnum.female; + case 'notSet': + return GenderEnum.notSet; + default: + return null; + } + } +} diff --git a/lib/features/auth/models/yandex_profile_model.dart b/lib/features/auth/models/yandex_profile_model.dart new file mode 100644 index 00000000..9bb84998 --- /dev/null +++ b/lib/features/auth/models/yandex_profile_model.dart @@ -0,0 +1,30 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'yandex_profile_model.freezed.dart'; +part 'yandex_profile_model.g.dart'; + +@freezed +abstract class YandexProfileModel with _$YandexProfileModel { + factory YandexProfileModel({ + required String id, + required String login, + required String birthday, + @JsonKey(name: 'client_id') required String clientId, + @JsonKey(name: 'display_name') required String displayName, + @JsonKey(name: 'real_name') required String realName, + @JsonKey(name: 'first_name') required String firstName, + @JsonKey(name: 'last_name') required String lastName, + @JsonKey(name: 'sex') required String sex, + @JsonKey(name: 'default_email') required String defaultEmail, + @JsonKey(name: 'default_phone') required YandexPhoneData defaultPhone, + }) = _YandexProfileModel; + + factory YandexProfileModel.fromJson(Map json) => _$YandexProfileModelFromJson(json); +} + +@freezed +abstract class YandexPhoneData with _$YandexPhoneData { + factory YandexPhoneData({required int id, required String number}) = _YandexPhoneData; + + factory YandexPhoneData.fromJson(Map json) => _$YandexPhoneDataFromJson(json); +} diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index 30ef29cf..6f49ea2a 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -1,6 +1,7 @@ // ignore_for_file: unused_element, duplicate_ignore import 'dart:async'; +import 'dart:developer'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/firebase_api.dart'; @@ -127,7 +128,7 @@ class _AuthScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _SocialButton( - onTap: () => _onVkPressed(context), + onTap: () => _onVkPressed(), backgroundColor: const Color(0xFF2787F5), tooltip: 'VK ID', child: SvgPicture.asset('assets/icons/vk_logo.svg', height: 24, width: 24), @@ -388,19 +389,14 @@ class _SocialButton extends StatelessWidget { } } -void _onVkPressed(BuildContext context) async { +void _onVkPressed() async { try { - final accessToken = await SocialAuthFacade.signInWithVk(context: context); + final accessToken = await SocialAuthFacade.signInWithVk(); if (accessToken == null) { return; } - final isAuthenticated = await sl().getTokenBySocial(provider: 'vk', accessToken: accessToken); - if (isAuthenticated) { - if (AppRouter.navigatorKey.currentContext != null) { - await _reconnectAfterAuth(); - } - } } catch (e) { + log(e.toString()); } } @@ -410,11 +406,8 @@ void _onOkPressed() async { if (accessToken == null) { return; } - final isAuthenticated = await sl().getTokenBySocial(provider: 'ok', accessToken: accessToken); - if (isAuthenticated) { - await _reconnectAfterAuth(); - } } catch (e) { + log(e.toString()); } } @@ -424,21 +417,14 @@ void _onMailRuPressed() async { if (accessToken == null) { return; } - final isAuthenticated = await sl().getTokenBySocial(provider: 'mailru', accessToken: accessToken); - if (isAuthenticated) { - await _reconnectAfterAuth(); - } } catch (e) { + log(e.toString()); } } void _onYandexPressed() async { final accessToken = await SocialAuthFacade.signInWithYandex(); if (accessToken == null) return; - final isAuthenticated = await sl().getTokenBySocial(provider: 'yandex', accessToken: accessToken); - if (isAuthenticated) { - await _reconnectAfterAuth(); - } } Future _reconnectAfterAuth() async { diff --git a/lib/features/auth/sdk/social_auth_facade.dart b/lib/features/auth/sdk/social_auth_facade.dart index 74959498..a235f03d 100644 --- a/lib/features/auth/sdk/social_auth_facade.dart +++ b/lib/features/auth/sdk/social_auth_facade.dart @@ -1,80 +1,125 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; +import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_login_yandex/flutter_login_yandex.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/env_config.dart'; +import 'package:http/http.dart' as http; import 'package:vk_id/vk_id.dart'; import 'package:webview_flutter/webview_flutter.dart'; -class VkAuthResult { - VkAuthResult({required this.accessToken, this.refreshToken, this.userId, this.firstName, this.lastName, this.email, this.phone, this.gender, this.birthDate}); - - final String accessToken; - final String? refreshToken; - final String? userId; - final String? firstName; - final String? lastName; - final String? email; - final String? phone; - final String? gender; - final String? birthDate; -} - class SocialAuthFacade { - static Future signInWithVk({required BuildContext context}) async { + static Future signInWithVk() async { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return null; final result = await signInWithVkAndFetchProfile(context: context); return result?.accessToken; } - static Future signInWithVkAndFetchProfile({required BuildContext context}) async { + static Future signInWithVkAndFetchProfile({required BuildContext context}) async { final clientIdStr = EnvConfig.vkClientId.trim(); if (clientIdStr.isEmpty) { return null; } try { - dynamic controller = VkIDController(clID: int.parse(clientIdStr)); + final controller = VkIDController(clID: int.parse(clientIdStr)); final authUri = controller.generateAuthorizeLinkWithCodeVerifier(); - String authUrl; - if (authUri is MapEntry) { - authUrl = authUri.value.toString(); - } else { - authUrl = authUri.toString(); - } - + final authUrl = authUri.value.toString(); + final codeVerifier = authUri.key; final redirectScheme = 'vk$clientIdStr://'; final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUrl, redirectSchemePrefix: redirectScheme); if (redirectParams == null) { return null; } - - final code = redirectParams['code']; - final deviceId = redirectParams['device_id']; - if (code == null || deviceId == null) { - return null; - } - return VkAuthResult(accessToken: code); + return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier); } catch (e) { return null; } } + static Future _parseVKAuthResult({ + required Map redirectParams, + required VkIDController controller, + required String codeVerifier, + }) async { + final code = redirectParams['code']; + final deviceId = redirectParams['device_id']; + final state = redirectParams['state']; + if (code == null || deviceId == null || state == null) { + return null; + } + final authRes = await controller.retrieveOAuthToken(authorizationCode: code, deviceId: deviceId, codeVerifier: codeVerifier, state: state); + final auth = authRes.result; + if (auth == null) { + var msg = "Retrieve OAuth token error"; + final err = authRes.error?.vkErr; + if (err != null) { + msg += ": ${err.error} - ${err.description}"; + } + log(msg); + return null; + } + final profileRes = await controller.getProfileInfo(); + final profile = profileRes.result; + if (profile == null) { + var msg = "Profile info load error"; + final err = profileRes.error?.vkErr; + if (err != null) { + msg += ": ${err.error} - ${err.description}"; + } + log(msg); + return null; + } + + return AuthResultEntity( + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + userId: profile.userId.toString(), + firstName: profile.firstName, + lastName: profile.lastName, + email: profile.email, + phone: profile.phone, + gender: GenderEnum.fromVkGender(profile.parsedSex), + birthDate: profile.bDayDt?.toString(), + ); + } + + Future?> exchangeCodeForToken(String code, String clientId, String clientSecret) async { + final response = await http.post( + Uri.parse('https://oauth.yandex.ru/token'), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: {'grant_type': 'authorization_code', 'code': code, 'client_id': clientId, 'client_secret': clientSecret}, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } else { + log('Error exchanging code: ${response.body}'); + } + return null; + } + static Future signInWithOk() async { final context = AppRouter.navigatorKey.currentContext; if (context == null) return null; - return _signInWithVkIdProvider(context: context, provider: 'ok'); + final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu); + return vkAuthRes?.accessToken; } static Future signInWithMailRu() async { final context = AppRouter.navigatorKey.currentContext; if (context == null) return null; - return _signInWithVkIdProvider(context: context, provider: 'mail'); + final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu); + return vkAuthRes?.accessToken; } - static Future signInWithYandex() async { + static Future signInWithYandex() async { final clientId = EnvConfig.yandexClientId.trim(); final redirectUri = EnvConfig.yandexRedirectUri.trim(); @@ -87,7 +132,7 @@ class SocialAuthFacade { if (context == null) return null; final authorizeUrl = Uri.https('oauth.yandex.ru', '/authorize', { - 'response_type': 'token', + 'response_type': 'code', 'client_id': clientId, 'redirect_uri': redirectUri, 'force_confirm': 'yes', @@ -99,62 +144,78 @@ class SocialAuthFacade { if (params == null) return null; - final token = params['access_token']; - if (token != null && token.isNotEmpty) { - return token; - } - final code = params['code']; - if (code != null && code.isNotEmpty) { - return code; - } - - return null; - } catch (e) { - try { - final plugin = FlutterLoginYandex(); - final response = await plugin.signIn(); - final token = (response is Map) ? response['token'] as String? : null; - return (token != null && token.isNotEmpty) ? token : null; - } catch (_) { + if (code == null || code.isEmpty) { return null; } + + final tokens = await _getTokensFomYandex(code: code, clientId: clientId, clientSecret: EnvConfig.yandexClientSecret); + if (tokens == null) { + return null; + } + final response = await http.get(Uri.parse('https://login.yandex.ru/info?format=json'), headers: {'Authorization': 'OAuth ${tokens.accessToken}'}); + + final Map json = jsonDecode(response.body); + final yandexProfileData = YandexProfileModel.fromJson(json); + return AuthResultEntity( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + userId: yandexProfileData.id, + firstName: yandexProfileData.firstName, + lastName: yandexProfileData.lastName, + email: yandexProfileData.defaultEmail, + phone: yandexProfileData.defaultPhone.number, + gender: GenderEnum.fromString(yandexProfileData.sex), + birthDate: yandexProfileData.birthday, + ); + } catch (e) { + log(e.toString()); } + return null; } - static Future _signInWithVkIdProvider({required BuildContext context, required String provider}) async { + static Future<({String accessToken, String refreshToken})?> _getTokensFomYandex({ + required String code, + required String clientId, + required String clientSecret, + }) async { + final response = await http.post( + Uri.parse('https://oauth.yandex.ru/token'), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: {'grant_type': 'authorization_code', 'code': code, 'client_id': clientId, 'client_secret': clientSecret}, + ); + + if (response.statusCode != 200) { + log('Yandex token exchange failed: ${response.body}'); + return null; + } + + final tokens = jsonDecode(response.body); + final accessToken = tokens['access_token'] as String; + final refreshToken = tokens['refresh_token'] as String; + return (accessToken: accessToken, refreshToken: refreshToken); + } + + static Future _signInWithVkIdProvider({required BuildContext context, required VkProvider provider}) async { final clientIdStr = EnvConfig.vkClientId.trim(); if (clientIdStr.isEmpty) { return null; } try { - dynamic controller = VkIDController(clID: int.parse(clientIdStr)); + final controller = VkIDController(clID: int.parse(clientIdStr)); - dynamic authUri; - try { - authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider); - } catch (e) { - try { - authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: null); - } catch (e2) { - authUri = controller.generateAuthorizeLinkWithCodeVerifier(); - } - } - - // Extract URL from MapEntry - String authUrl; - if (authUri is MapEntry) { - authUrl = authUri.value.toString(); - } else { - authUrl = authUri.toString(); - } + final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider); + final codeVerifier = authUri.key; + final authUrl = authUri.value.toString(); final redirectScheme = 'vk$clientIdStr://'; final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUrl, redirectSchemePrefix: redirectScheme); if (redirectParams == null) return null; + final code = redirectParams['code']; final deviceId = redirectParams['device_id']; if (code == null || deviceId == null) return null; - return code; + + return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier); } catch (e) { return null; } @@ -190,12 +251,9 @@ class SocialAuthFacade { } return NavigationDecision.navigate; }, - onPageStarted: (url) { - }, - onPageFinished: (url) { - }, - onWebResourceError: (error) { - }, + onPageStarted: (url) {}, + onPageFinished: (url) {}, + onWebResourceError: (error) {}, ), ); @@ -204,7 +262,7 @@ class SocialAuthFacade { } catch (e) { return null; } - + if (!context.mounted) return null; return showDialog>( context: context, barrierDismissible: false,