forked from eatvio/eatvio_app
Merge pull request 'feat(socialAuth)' (#717) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#717
This commit is contained in:
@@ -7,7 +7,7 @@ abstract class BaseCubit<State> extends Cubit<State> {
|
||||
|
||||
/// Унифицированная обработка ошибок
|
||||
void handleError(Object error, [StackTrace? stackTrace]) {
|
||||
log('Error in ${runtimeType}: $error', stackTrace: stackTrace);
|
||||
log('Error in $runtimeType: $error', stackTrace: stackTrace);
|
||||
// Здесь можно добавить отправку ошибок в Sentry или другую систему мониторинга
|
||||
}
|
||||
|
||||
|
||||
51
lib/features/auth/entities/auth_result_entity.dart
Normal file
51
lib/features/auth/entities/auth_result_entity.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lib/features/auth/models/yandex_profile_model.dart
Normal file
30
lib/features/auth/models/yandex_profile_model.dart
Normal file
@@ -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<String, dynamic> json) => _$YandexProfileModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class YandexPhoneData with _$YandexPhoneData {
|
||||
factory YandexPhoneData({required int id, required String number}) = _YandexPhoneData;
|
||||
|
||||
factory YandexPhoneData.fromJson(Map<String, dynamic> json) => _$YandexPhoneDataFromJson(json);
|
||||
}
|
||||
@@ -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<AuthScreen> {
|
||||
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<AuthCubit>().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<AuthCubit>().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<AuthCubit>().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<AuthCubit>().getTokenBySocial(provider: 'yandex', accessToken: accessToken);
|
||||
if (isAuthenticated) {
|
||||
await _reconnectAfterAuth();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconnectAfterAuth() async {
|
||||
|
||||
@@ -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<String?> signInWithVk({required BuildContext context}) async {
|
||||
static Future<String?> signInWithVk() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return null;
|
||||
final result = await signInWithVkAndFetchProfile(context: context);
|
||||
return result?.accessToken;
|
||||
}
|
||||
|
||||
static Future<VkAuthResult?> signInWithVkAndFetchProfile({required BuildContext context}) async {
|
||||
static Future<AuthResultEntity?> 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<AuthResultEntity?> _parseVKAuthResult({
|
||||
required Map<String, String> 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<Map<String, dynamic>?> 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<String, dynamic>;
|
||||
} else {
|
||||
log('Error exchanging code: ${response.body}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> 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<String?> 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<String?> signInWithYandex() async {
|
||||
static Future<AuthResultEntity?> 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<String, dynamic> 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<String?> _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<AuthResultEntity?> _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<Map<String, String>>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
|
||||
Reference in New Issue
Block a user