🐛 Fix bugs and optimize your auth experience
This commit is contained in:
parent
7b45d95fd6
commit
6b0f644353
@ -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'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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!;
|
||||||
|
@ -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),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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];
|
||||||
|
|
||||||
|
@ -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",
|
"name": "Solian",
|
||||||
"short_name": "solian",
|
"short_name": "Solian",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
Loading…
Reference in New Issue
Block a user