diff --git a/lib/providers/account.dart b/lib/providers/account.dart index 65c438f..e02dd86 100644 --- a/lib/providers/account.dart +++ b/lib/providers/account.dart @@ -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'), ); diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index d72712c..b5c2043 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -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 json) => TokenSet( + accessToken: json['access_token'], + refreshToken: json['refresh_token'], + expiredAt: json['expired_at'] != null + ? DateTime.parse(json['expired_at']) + : null, + ); + + Map 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 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> requestAuthenticator(Request 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 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 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 signin( + Future 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().connect(); Get.find().notifyPrefetch(); Get.find().connect(); - return credentials!; + return globalCredentials!; } void signout() { diff --git a/lib/providers/chat.dart b/lib/providers/chat.dart index 9924084..45941f8 100644 --- a/lib/providers/chat.dart +++ b/lib/providers/chat.dart @@ -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'), ); diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 1c85cb2..0fdabab 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -36,7 +36,11 @@ class _AccountScreenState extends State { 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 { 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 { future: provider.getProfile(), builder: (context, snapshot) { if (!snapshot.hasData) { - return Container(); + return const LinearProgressIndicator(); } final prof = snapshot.data!; diff --git a/lib/screens/auth/signin.dart b/lib/screens/auth/signin.dart index a3aa1e6..2ecc387 100644 --- a/lib/screens/auth/signin.dart +++ b/lib/screens/auth/signin.dart @@ -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 { + 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 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().registerPushNotifications(); + + Navigator.pop(context, true); } @override @@ -120,6 +118,7 @@ class _SignInPopupState extends State { 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 { const Icon(Icons.chevron_right), ], ), - onPressed: () => performAction(context), ), ) ], diff --git a/lib/screens/chat.dart b/lib/screens/chat.dart index 526dadf..c7989ac 100644 --- a/lib/screens/chat.dart +++ b/lib/screens/chat.dart @@ -29,11 +29,16 @@ class _ChatScreenState extends State { 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(); diff --git a/lib/screens/realms.dart b/lib/screens/realms.dart index e0aa475..e9659a2 100644 --- a/lib/screens/realms.dart +++ b/lib/screens/realms.dart @@ -25,6 +25,9 @@ class _RealmListScreenState extends State { final List _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(); diff --git a/lib/services.dart b/lib/services.dart index 97b49da..d62eb51 100644 --- a/lib/services.dart +++ b/lib/services.dart @@ -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]; diff --git a/lib/widgets/account/push_notify_register_dialog.dart b/lib/widgets/account/push_notify_register_dialog.dart deleted file mode 100644 index 4b23f39..0000000 --- a/lib/widgets/account/push_notify_register_dialog.dart +++ /dev/null @@ -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 createState() => - _PushNotifyRegisterDialogState(); -} - -class _PushNotifyRegisterDialogState extends State { - bool _isBusy = false; - - void performAction() async { - setState(() => _isBusy = true); - - try { - await Get.find().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), - ), - ], - ); - } -} diff --git a/web/manifest.json b/web/manifest.json index 2e16699..b0b0fec 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "solian", - "short_name": "solian", + "name": "Solian", + "short_name": "Solian", "start_url": ".", "display": "standalone", "background_color": "#ffffff",