🐛 Fix bugs and optimize your auth experience
This commit is contained in:
		| @@ -56,10 +56,10 @@ class AccountProvider extends GetxController { | ||||
|     final AuthProvider auth = Get.find(); | ||||
|     await auth.ensureCredentials(); | ||||
|  | ||||
|     if (auth.credentials == null) await auth.loadCredentials(); | ||||
|     if (globalCredentials == null) await auth.loadCredentials(); | ||||
|  | ||||
|     final uri = Uri.parse( | ||||
|       '${ServiceFinder.services['passport']}/api/ws?tk=${auth.credentials!.accessToken}' | ||||
|       '${ServiceFinder.services['passport']}/api/ws?tk=${globalCredentials!.accessToken}' | ||||
|           .replaceFirst('http', 'ws'), | ||||
|     ); | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,42 @@ import 'package:solian/controllers/chat_events_controller.dart'; | ||||
| import 'package:solian/providers/account.dart'; | ||||
| import 'package:solian/providers/chat.dart'; | ||||
| import 'package:solian/services.dart'; | ||||
| import 'package:oauth2/oauth2.dart' as oauth2; | ||||
|  | ||||
| class TokenSet { | ||||
|   final String accessToken; | ||||
|   final String refreshToken; | ||||
|   final DateTime? expiredAt; | ||||
|  | ||||
|   TokenSet({ | ||||
|     required this.accessToken, | ||||
|     required this.refreshToken, | ||||
|     this.expiredAt, | ||||
|   }); | ||||
|  | ||||
|   factory TokenSet.fromJson(Map<String, dynamic> json) => TokenSet( | ||||
|         accessToken: json['access_token'], | ||||
|         refreshToken: json['refresh_token'], | ||||
|         expiredAt: json['expired_at'] != null | ||||
|             ? DateTime.parse(json['expired_at']) | ||||
|             : null, | ||||
|       ); | ||||
|  | ||||
|   Map<String, dynamic> toJson() => { | ||||
|         'access_token': accessToken, | ||||
|         'refresh_token': refreshToken, | ||||
|         'expired_at': expiredAt?.toIso8601String(), | ||||
|       }; | ||||
|  | ||||
|   bool get isExpired => expiredAt?.isBefore(DateTime.now()) ?? true; | ||||
| } | ||||
|  | ||||
| TokenSet? globalCredentials; | ||||
|  | ||||
| class RiskyAuthenticateException implements Exception { | ||||
|   final int ticketId; | ||||
|  | ||||
|   RiskyAuthenticateException(this.ticketId); | ||||
| } | ||||
|  | ||||
| class AuthProvider extends GetConnect { | ||||
|   final tokenEndpoint = | ||||
| @@ -27,33 +62,29 @@ class AuthProvider extends GetConnect { | ||||
|     loadCredentials(); | ||||
|   } | ||||
|  | ||||
|   oauth2.Credentials? credentials; | ||||
|  | ||||
|   Future<void> refreshCredentials() async { | ||||
|     final resp = await post('/api/auth/token', { | ||||
|       'refresh_token': credentials!.refreshToken, | ||||
|       'refresh_token': globalCredentials!.refreshToken, | ||||
|       'grant_type': 'refresh_token', | ||||
|     }); | ||||
|     if (resp.statusCode != 200) { | ||||
|       throw Exception(resp.bodyString); | ||||
|     } | ||||
|     credentials = oauth2.Credentials( | ||||
|       resp.body['access_token'], | ||||
|     globalCredentials = TokenSet( | ||||
|       accessToken: resp.body['access_token'], | ||||
|       refreshToken: resp.body['refresh_token'], | ||||
|       idToken: resp.body['access_token'], | ||||
|       tokenEndpoint: tokenEndpoint, | ||||
|       expiration: DateTime.now().add(const Duration(minutes: 3)), | ||||
|       expiredAt: DateTime.now().add(const Duration(minutes: 3)), | ||||
|     ); | ||||
|     storage.write( | ||||
|       key: 'auth_credentials', | ||||
|       value: jsonEncode(credentials!.toJson()), | ||||
|       value: jsonEncode(globalCredentials!.toJson()), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<Request<T?>> requestAuthenticator<T>(Request<T?> request) async { | ||||
|     try { | ||||
|       await ensureCredentials(); | ||||
|       request.headers['Authorization'] = 'Bearer ${credentials!.accessToken}'; | ||||
|       request.headers['Authorization'] = 'Bearer ${globalCredentials!.accessToken}'; | ||||
|     } catch (_) {} | ||||
|  | ||||
|     return request; | ||||
| @@ -66,7 +97,8 @@ class AuthProvider extends GetConnect { | ||||
|     final client = GetConnect( | ||||
|       maxAuthRetries: 3, | ||||
|       timeout: timeout, | ||||
|       allowAutoSignedCert: true, | ||||
|       userAgent: 'Solian/1.1', | ||||
|       sendUserAgent: true, | ||||
|     ); | ||||
|     client.httpClient.addAuthenticator(requestAuthenticator); | ||||
|     client.httpClient.baseUrl = ServiceFinder.services[service]; | ||||
| @@ -76,9 +108,9 @@ class AuthProvider extends GetConnect { | ||||
|  | ||||
|   Future<void> ensureCredentials() async { | ||||
|     if (!await isAuthorized) throw Exception('unauthorized'); | ||||
|     if (credentials == null) await loadCredentials(); | ||||
|     if (globalCredentials == null) await loadCredentials(); | ||||
|  | ||||
|     if (credentials!.isExpired) { | ||||
|     if (globalCredentials!.isExpired) { | ||||
|       await refreshCredentials(); | ||||
|       log('Refreshed credentials at ${DateTime.now()}'); | ||||
|     } | ||||
| @@ -87,45 +119,55 @@ class AuthProvider extends GetConnect { | ||||
|   Future<void> loadCredentials() async { | ||||
|     if (await isAuthorized) { | ||||
|       final content = await storage.read(key: 'auth_credentials'); | ||||
|       credentials = oauth2.Credentials.fromJson(jsonDecode(content!)); | ||||
|       globalCredentials = TokenSet.fromJson(jsonDecode(content!)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<oauth2.Credentials> signin( | ||||
|   Future<TokenSet> signin( | ||||
|     BuildContext context, | ||||
|     String username, | ||||
|     String password, | ||||
|   ) async { | ||||
|     _cachedUserProfileResponse = null; | ||||
|  | ||||
|     final resp = await oauth2.resourceOwnerPasswordGrant( | ||||
|       tokenEndpoint, | ||||
|       username, | ||||
|       password, | ||||
|       identifier: clientId, | ||||
|       secret: clientSecret, | ||||
|       scopes: ['*'], | ||||
|       basicAuth: false, | ||||
|     ); | ||||
|     final client = ServiceFinder.configureClient('passport'); | ||||
|  | ||||
|     credentials = oauth2.Credentials( | ||||
|       resp.credentials.accessToken, | ||||
|       refreshToken: resp.credentials.refreshToken!, | ||||
|       idToken: resp.credentials.accessToken, | ||||
|       tokenEndpoint: tokenEndpoint, | ||||
|       expiration: DateTime.now().add(const Duration(minutes: 3)), | ||||
|     // Create ticket | ||||
|     final resp = await client.post('/api/auth', { | ||||
|       'username': username, | ||||
|       'password': password, | ||||
|     }); | ||||
|     if (resp.statusCode != 200) { | ||||
|       throw Exception(resp.body); | ||||
|     } else if (resp.body['is_finished'] == false) { | ||||
|       throw RiskyAuthenticateException(resp.body['ticket']['id']); | ||||
|     } | ||||
|  | ||||
|     // Assign token | ||||
|     final tokenResp = await post('/api/auth/token', { | ||||
|       'code': resp.body['ticket']['grant_token'], | ||||
|       'grant_type': 'grant_token', | ||||
|     }); | ||||
|     if (tokenResp.statusCode != 200) { | ||||
|       throw Exception(tokenResp.bodyString); | ||||
|     } | ||||
|  | ||||
|     globalCredentials = TokenSet( | ||||
|       accessToken: tokenResp.body['access_token'], | ||||
|       refreshToken: tokenResp.body['refresh_token'], | ||||
|       expiredAt: DateTime.now().add(const Duration(minutes: 3)), | ||||
|     ); | ||||
|  | ||||
|     storage.write( | ||||
|       key: 'auth_credentials', | ||||
|       value: jsonEncode(credentials!.toJson()), | ||||
|       value: jsonEncode(globalCredentials!.toJson()), | ||||
|     ); | ||||
|  | ||||
|     Get.find<AccountProvider>().connect(); | ||||
|     Get.find<AccountProvider>().notifyPrefetch(); | ||||
|     Get.find<ChatProvider>().connect(); | ||||
|  | ||||
|     return credentials!; | ||||
|     return globalCredentials!; | ||||
|   } | ||||
|  | ||||
|   void signout() { | ||||
|   | ||||
| @@ -26,8 +26,10 @@ class ChatProvider extends GetxController { | ||||
|     final AuthProvider auth = Get.find(); | ||||
|     await auth.ensureCredentials(); | ||||
|  | ||||
|     if (globalCredentials == null) await auth.loadCredentials(); | ||||
|  | ||||
|     final uri = Uri.parse( | ||||
|       '${ServiceFinder.services['messaging']}/api/ws?tk=${auth.credentials!.accessToken}' | ||||
|       '${ServiceFinder.services['messaging']}/api/ws?tk=${globalCredentials!.accessToken}' | ||||
|           .replaceFirst('http', 'ws'), | ||||
|     ); | ||||
|  | ||||
|   | ||||
| @@ -36,7 +36,11 @@ class _AccountScreenState extends State<AccountScreen> { | ||||
|         child: FutureBuilder( | ||||
|           future: provider.isAuthorized, | ||||
|           builder: (context, snapshot) { | ||||
|             if (!snapshot.hasData || snapshot.data == false) { | ||||
|             if (!snapshot.hasData) { | ||||
|               return const Center(child: CircularProgressIndicator()); | ||||
|             } | ||||
|  | ||||
|             if (snapshot.hasData && snapshot.data == false) { | ||||
|               return Center( | ||||
|                 child: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
| @@ -52,9 +56,11 @@ class _AccountScreenState extends State<AccountScreen> { | ||||
|                           isScrollControlled: true, | ||||
|                           context: context, | ||||
|                           builder: (context) => const SignInPopup(), | ||||
|                         ).then((_) async { | ||||
|                           await provider.getProfile(noCache: true); | ||||
|                           setState(() {}); | ||||
|                         ).then((val) async { | ||||
|                           if (val == true) { | ||||
|                             await provider.getProfile(noCache: true); | ||||
|                             setState(() {}); | ||||
|                           } | ||||
|                         }); | ||||
|                       }, | ||||
|                     ), | ||||
| @@ -136,7 +142,7 @@ class _AccountHeadingState extends State<AccountHeading> { | ||||
|       future: provider.getProfile(), | ||||
|       builder: (context, snapshot) { | ||||
|         if (!snapshot.hasData) { | ||||
|           return Container(); | ||||
|           return const LinearProgressIndicator(); | ||||
|         } | ||||
|  | ||||
|         final prof = snapshot.data!; | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:get/get.dart'; | ||||
| import 'package:solian/exts.dart'; | ||||
| import 'package:solian/providers/account.dart'; | ||||
| import 'package:solian/providers/auth.dart'; | ||||
| import 'package:solian/services.dart'; | ||||
| import 'package:solian/widgets/account/push_notify_register_dialog.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class SignInPopup extends StatefulWidget { | ||||
| @@ -14,56 +14,54 @@ class SignInPopup extends StatefulWidget { | ||||
| } | ||||
|  | ||||
| class _SignInPopupState extends State<SignInPopup> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   final _usernameController = TextEditingController(); | ||||
|   final _passwordController = TextEditingController(); | ||||
|  | ||||
|   void performAction(BuildContext context) { | ||||
|   void performAction(BuildContext context) async { | ||||
|     final AuthProvider provider = Get.find(); | ||||
|  | ||||
|     final username = _usernameController.value.text; | ||||
|     final password = _passwordController.value.text; | ||||
|     if (username.isEmpty || password.isEmpty) return; | ||||
|     provider.signin(context, username, password).then((_) async { | ||||
|       await showDialog( | ||||
|         useRootNavigator: true, | ||||
|         context: context, | ||||
|         builder: (context) => const PushNotifyRegisterDialog(), | ||||
|       ); | ||||
|  | ||||
|       Navigator.pop(context, true); | ||||
|     }).catchError((e) { | ||||
|       List<String> messages = e.toString().split('\n'); | ||||
|       if (messages.last.contains('risk')) { | ||||
|         final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last); | ||||
|         if (ticketId == null) { | ||||
|           context.showErrorDialog( | ||||
|             'Requested to multi-factor authenticate, but the ticket id was not found', | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       await provider.signin(context, username, password); | ||||
|     } on RiskyAuthenticateException catch (e) { | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: (context) { | ||||
|           return AlertDialog( | ||||
|             title: Text('riskDetection'.tr), | ||||
|             content: Text('signinRiskDetected'.tr), | ||||
|             actions: [ | ||||
|               TextButton( | ||||
|                 child: Text('next'.tr), | ||||
|                 onPressed: () { | ||||
|                   launchUrlString( | ||||
|                     '${ServiceFinder.services['passport']}/mfa?close=yes&ticketId=${e.ticketId}', | ||||
|                   ); | ||||
|                   Navigator.pop(context); | ||||
|                 }, | ||||
|               ) | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         showDialog( | ||||
|           context: context, | ||||
|           builder: (context) { | ||||
|             return AlertDialog( | ||||
|               title: Text('riskDetection'.tr), | ||||
|               content: Text('signinRiskDetected'.tr), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   child: Text('next'.tr), | ||||
|                   onPressed: () { | ||||
|                     launchUrlString( | ||||
|                       '${ServiceFinder.services['passport']}/mfa?ticket=${ticketId!.group(1)}', | ||||
|                     ); | ||||
|                     Navigator.pop(context); | ||||
|                   }, | ||||
|                 ) | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       } else { | ||||
|         context.showErrorDialog(messages.last); | ||||
|       } | ||||
|     }); | ||||
|         }, | ||||
|       ); | ||||
|       return; | ||||
|     } catch (e) { | ||||
|       context.showErrorDialog(e); | ||||
|       return; | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|  | ||||
|     Get.find<AccountProvider>().registerPushNotifications(); | ||||
|  | ||||
|     Navigator.pop(context, true); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -120,6 +118,7 @@ class _SignInPopupState extends State<SignInPopup> { | ||||
|               Align( | ||||
|                 alignment: Alignment.centerRight, | ||||
|                 child: TextButton( | ||||
|                   onPressed: _isBusy ? null : () => performAction(context), | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
| @@ -127,7 +126,6 @@ class _SignInPopupState extends State<SignInPopup> { | ||||
|                       const Icon(Icons.chevron_right), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onPressed: () => performAction(context), | ||||
|                 ), | ||||
|               ) | ||||
|             ], | ||||
|   | ||||
| @@ -29,11 +29,16 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|  | ||||
|   getProfile() async { | ||||
|     final AuthProvider auth = Get.find(); | ||||
|     if (!await auth.isAuthorized) return; | ||||
|      | ||||
|     final prof = await auth.getProfile(); | ||||
|     _accountId = prof.body['id']; | ||||
|   } | ||||
|  | ||||
|   getChannels() async { | ||||
|     final AuthProvider auth = Get.find(); | ||||
|     if (!await auth.isAuthorized) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final ChannelProvider provider = Get.find(); | ||||
|   | ||||
| @@ -25,6 +25,9 @@ class _RealmListScreenState extends State<RealmListScreen> { | ||||
|   final List<Realm> _realms = List.empty(growable: true); | ||||
|  | ||||
|   getRealms() async { | ||||
|     final AuthProvider auth = Get.find(); | ||||
|     if (!await auth.isAuthorized) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final RealmProvider provider = Get.find(); | ||||
|   | ||||
| @@ -16,7 +16,8 @@ abstract class ServiceFinder { | ||||
|       {timeout = const Duration(seconds: 5)}) { | ||||
|     final client = GetConnect( | ||||
|       timeout: timeout, | ||||
|       allowAutoSignedCert: true, | ||||
|       userAgent: 'Solian/1.1', | ||||
|       sendUserAgent: true, | ||||
|     ); | ||||
|     client.httpClient.baseUrl = ServiceFinder.services[service]; | ||||
|  | ||||
|   | ||||
| @@ -1,48 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:get/get.dart'; | ||||
| import 'package:solian/exts.dart'; | ||||
| import 'package:solian/providers/account.dart'; | ||||
|  | ||||
| class PushNotifyRegisterDialog extends StatefulWidget { | ||||
|   const PushNotifyRegisterDialog({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<PushNotifyRegisterDialog> createState() => | ||||
|       _PushNotifyRegisterDialogState(); | ||||
| } | ||||
|  | ||||
| class _PushNotifyRegisterDialogState extends State<PushNotifyRegisterDialog> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   void performAction() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       await Get.find<AccountProvider>().registerPushNotifications(); | ||||
|       context.showSnackbar('pushNotifyRegisterDone'.tr); | ||||
|       Navigator.pop(context); | ||||
|     } catch (e) { | ||||
|       context.showErrorDialog(e); | ||||
|     } | ||||
|  | ||||
|     setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text('pushNotifyRegister'.tr), | ||||
|       content: Text('pushNotifyRegisterCaption'.tr), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => Navigator.pop(context), | ||||
|           child: Text('cancel'.tr), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : performAction, | ||||
|           child: Text('confirm'.tr), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|     "name": "solian", | ||||
|     "short_name": "solian", | ||||
|     "name": "Solian", | ||||
|     "short_name": "Solian", | ||||
|     "start_url": ".", | ||||
|     "display": "standalone", | ||||
|     "background_color": "#ffffff", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user