🐛 Fix bugs and optimize your auth experience

This commit is contained in:
LittleSheep 2024-06-29 17:35:18 +08:00
parent 7b45d95fd6
commit 6b0f644353
10 changed files with 143 additions and 134 deletions

View File

@ -56,10 +56,10 @@ class AccountProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await auth.ensureCredentials(); await auth.ensureCredentials();
if (auth.credentials == null) await auth.loadCredentials(); if (globalCredentials == null) await auth.loadCredentials();
final uri = Uri.parse( final uri = Uri.parse(
'${ServiceFinder.services['passport']}/api/ws?tk=${auth.credentials!.accessToken}' '${ServiceFinder.services['passport']}/api/ws?tk=${globalCredentials!.accessToken}'
.replaceFirst('http', 'ws'), .replaceFirst('http', 'ws'),
); );

View File

@ -10,7 +10,42 @@ import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/providers/account.dart'; import 'package:solian/providers/account.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/services.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 { class AuthProvider extends GetConnect {
final tokenEndpoint = final tokenEndpoint =
@ -27,33 +62,29 @@ class AuthProvider extends GetConnect {
loadCredentials(); loadCredentials();
} }
oauth2.Credentials? credentials;
Future<void> refreshCredentials() async { Future<void> refreshCredentials() async {
final resp = await post('/api/auth/token', { final resp = await post('/api/auth/token', {
'refresh_token': credentials!.refreshToken, 'refresh_token': globalCredentials!.refreshToken,
'grant_type': 'refresh_token', 'grant_type': 'refresh_token',
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw Exception(resp.bodyString);
} }
credentials = oauth2.Credentials( globalCredentials = TokenSet(
resp.body['access_token'], accessToken: resp.body['access_token'],
refreshToken: resp.body['refresh_token'], refreshToken: resp.body['refresh_token'],
idToken: resp.body['access_token'], expiredAt: DateTime.now().add(const Duration(minutes: 3)),
tokenEndpoint: tokenEndpoint,
expiration: DateTime.now().add(const Duration(minutes: 3)),
); );
storage.write( storage.write(
key: 'auth_credentials', key: 'auth_credentials',
value: jsonEncode(credentials!.toJson()), value: jsonEncode(globalCredentials!.toJson()),
); );
} }
Future<Request<T?>> requestAuthenticator<T>(Request<T?> request) async { Future<Request<T?>> requestAuthenticator<T>(Request<T?> request) async {
try { try {
await ensureCredentials(); await ensureCredentials();
request.headers['Authorization'] = 'Bearer ${credentials!.accessToken}'; request.headers['Authorization'] = 'Bearer ${globalCredentials!.accessToken}';
} catch (_) {} } catch (_) {}
return request; return request;
@ -66,7 +97,8 @@ class AuthProvider extends GetConnect {
final client = GetConnect( final client = GetConnect(
maxAuthRetries: 3, maxAuthRetries: 3,
timeout: timeout, timeout: timeout,
allowAutoSignedCert: true, userAgent: 'Solian/1.1',
sendUserAgent: true,
); );
client.httpClient.addAuthenticator(requestAuthenticator); client.httpClient.addAuthenticator(requestAuthenticator);
client.httpClient.baseUrl = ServiceFinder.services[service]; client.httpClient.baseUrl = ServiceFinder.services[service];
@ -76,9 +108,9 @@ class AuthProvider extends GetConnect {
Future<void> ensureCredentials() async { Future<void> ensureCredentials() async {
if (!await isAuthorized) throw Exception('unauthorized'); if (!await isAuthorized) throw Exception('unauthorized');
if (credentials == null) await loadCredentials(); if (globalCredentials == null) await loadCredentials();
if (credentials!.isExpired) { if (globalCredentials!.isExpired) {
await refreshCredentials(); await refreshCredentials();
log('Refreshed credentials at ${DateTime.now()}'); log('Refreshed credentials at ${DateTime.now()}');
} }
@ -87,45 +119,55 @@ class AuthProvider extends GetConnect {
Future<void> loadCredentials() async { Future<void> loadCredentials() async {
if (await isAuthorized) { if (await isAuthorized) {
final content = await storage.read(key: 'auth_credentials'); 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, BuildContext context,
String username, String username,
String password, String password,
) async { ) async {
_cachedUserProfileResponse = null; _cachedUserProfileResponse = null;
final resp = await oauth2.resourceOwnerPasswordGrant( final client = ServiceFinder.configureClient('passport');
tokenEndpoint,
username,
password,
identifier: clientId,
secret: clientSecret,
scopes: ['*'],
basicAuth: false,
);
credentials = oauth2.Credentials( // Create ticket
resp.credentials.accessToken, final resp = await client.post('/api/auth', {
refreshToken: resp.credentials.refreshToken!, 'username': username,
idToken: resp.credentials.accessToken, 'password': password,
tokenEndpoint: tokenEndpoint, });
expiration: DateTime.now().add(const Duration(minutes: 3)), 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( storage.write(
key: 'auth_credentials', key: 'auth_credentials',
value: jsonEncode(credentials!.toJson()), value: jsonEncode(globalCredentials!.toJson()),
); );
Get.find<AccountProvider>().connect(); Get.find<AccountProvider>().connect();
Get.find<AccountProvider>().notifyPrefetch(); Get.find<AccountProvider>().notifyPrefetch();
Get.find<ChatProvider>().connect(); Get.find<ChatProvider>().connect();
return credentials!; return globalCredentials!;
} }
void signout() { void signout() {

View File

@ -26,8 +26,10 @@ class ChatProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await auth.ensureCredentials(); await auth.ensureCredentials();
if (globalCredentials == null) await auth.loadCredentials();
final uri = Uri.parse( final uri = Uri.parse(
'${ServiceFinder.services['messaging']}/api/ws?tk=${auth.credentials!.accessToken}' '${ServiceFinder.services['messaging']}/api/ws?tk=${globalCredentials!.accessToken}'
.replaceFirst('http', 'ws'), .replaceFirst('http', 'ws'),
); );

View File

@ -36,7 +36,11 @@ class _AccountScreenState extends State<AccountScreen> {
child: FutureBuilder( child: FutureBuilder(
future: provider.isAuthorized, future: provider.isAuthorized,
builder: (context, snapshot) { 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( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -52,9 +56,11 @@ class _AccountScreenState extends State<AccountScreen> {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => const SignInPopup(), builder: (context) => const SignInPopup(),
).then((_) async { ).then((val) async {
await provider.getProfile(noCache: true); if (val == true) {
setState(() {}); await provider.getProfile(noCache: true);
setState(() {});
}
}); });
}, },
), ),
@ -136,7 +142,7 @@ class _AccountHeadingState extends State<AccountHeading> {
future: provider.getProfile(), future: provider.getProfile(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return Container(); return const LinearProgressIndicator();
} }
final prof = snapshot.data!; final prof = snapshot.data!;

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/account.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/push_notify_register_dialog.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SignInPopup extends StatefulWidget { class SignInPopup extends StatefulWidget {
@ -14,56 +14,54 @@ class SignInPopup extends StatefulWidget {
} }
class _SignInPopupState extends State<SignInPopup> { class _SignInPopupState extends State<SignInPopup> {
bool _isBusy = false;
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
void performAction(BuildContext context) { void performAction(BuildContext context) async {
final AuthProvider provider = Get.find(); final AuthProvider provider = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
final password = _passwordController.value.text; final password = _passwordController.value.text;
if (username.isEmpty || password.isEmpty) return; 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); setState(() => _isBusy = true);
}).catchError((e) {
List<String> messages = e.toString().split('\n'); try {
if (messages.last.contains('risk')) { await provider.signin(context, username, password);
final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last); } on RiskyAuthenticateException catch (e) {
if (ticketId == null) { showDialog(
context.showErrorDialog( context: context,
'Requested to multi-factor authenticate, but the ticket id was not found', 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, return;
builder: (context) { } catch (e) {
return AlertDialog( context.showErrorDialog(e);
title: Text('riskDetection'.tr), return;
content: Text('signinRiskDetected'.tr), } finally {
actions: [ setState(() => _isBusy = false);
TextButton( }
child: Text('next'.tr),
onPressed: () { Get.find<AccountProvider>().registerPushNotifications();
launchUrlString(
'${ServiceFinder.services['passport']}/mfa?ticket=${ticketId!.group(1)}', Navigator.pop(context, true);
);
Navigator.pop(context);
},
)
],
);
},
);
} else {
context.showErrorDialog(messages.last);
}
});
} }
@override @override
@ -120,6 +118,7 @@ class _SignInPopupState extends State<SignInPopup> {
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(
onPressed: _isBusy ? null : () => performAction(context),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -127,7 +126,6 @@ class _SignInPopupState extends State<SignInPopup> {
const Icon(Icons.chevron_right), const Icon(Icons.chevron_right),
], ],
), ),
onPressed: () => performAction(context),
), ),
) )
], ],

View File

@ -29,11 +29,16 @@ class _ChatScreenState extends State<ChatScreen> {
getProfile() async { getProfile() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final prof = await auth.getProfile(); final prof = await auth.getProfile();
_accountId = prof.body['id']; _accountId = prof.body['id'];
} }
getChannels() async { getChannels() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
final ChannelProvider provider = Get.find(); final ChannelProvider provider = Get.find();

View File

@ -25,6 +25,9 @@ class _RealmListScreenState extends State<RealmListScreen> {
final List<Realm> _realms = List.empty(growable: true); final List<Realm> _realms = List.empty(growable: true);
getRealms() async { getRealms() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
final RealmProvider provider = Get.find(); final RealmProvider provider = Get.find();

View File

@ -16,7 +16,8 @@ abstract class ServiceFinder {
{timeout = const Duration(seconds: 5)}) { {timeout = const Duration(seconds: 5)}) {
final client = GetConnect( final client = GetConnect(
timeout: timeout, timeout: timeout,
allowAutoSignedCert: true, userAgent: 'Solian/1.1',
sendUserAgent: true,
); );
client.httpClient.baseUrl = ServiceFinder.services[service]; client.httpClient.baseUrl = ServiceFinder.services[service];

View File

@ -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),
),
],
);
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "solian", "name": "Solian",
"short_name": "solian", "short_name": "Solian",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",