Improve handling of logout and token expiration

This commit is contained in:
Nutcake 2023-05-25 18:54:21 +02:00
parent 730de37b78
commit 5561f18a2c
6 changed files with 142 additions and 136 deletions

View file

@ -14,8 +14,9 @@ class ClientHolder extends InheritedWidget {
super.key, super.key,
required AuthenticationData authenticationData, required AuthenticationData authenticationData,
required this.settingsClient, required this.settingsClient,
required super.child required super.child,
}) : apiClient = ApiClient(authenticationData: authenticationData); required Function() onLogout,
}) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout);
static ClientHolder? maybeOf(BuildContext context) { static ClientHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ClientHolder>(); return context.dependOnInheritedWidgetOfExactType<ClientHolder>();

View file

@ -18,10 +18,12 @@ class ApiClient {
static const String tokenKey = "token"; static const String tokenKey = "token";
static const String passwordKey = "password"; static const String passwordKey = "password";
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData;
final AuthenticationData _authenticationData; final AuthenticationData _authenticationData;
final Logger _logger = Logger("API"); final Logger _logger = Logger("API");
// Saving the context here feels kinda cringe ngl
final Function() onLogout;
AuthenticationData get authenticationData => _authenticationData; AuthenticationData get authenticationData => _authenticationData;
String get userId => _authenticationData.userId; String get userId => _authenticationData.userId;
@ -103,7 +105,7 @@ class ApiClient {
return AuthenticationData.unauthenticated(); return AuthenticationData.unauthenticated();
} }
Future<void> logout(BuildContext context) async { Future<void> logout() async {
const FlutterSecureStorage storage = FlutterSecureStorage( const FlutterSecureStorage storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
); );
@ -111,9 +113,7 @@ class ApiClient {
await storage.delete(key: machineIdKey); await storage.delete(key: machineIdKey);
await storage.delete(key: tokenKey); await storage.delete(key: tokenKey);
await storage.delete(key: passwordKey); await storage.delete(key: passwordKey);
if (context.mounted) { onLogout();
Phoenix.rebirth(context);
}
} }
Future<void> extendSession() async { Future<void> extendSession() async {
@ -127,7 +127,7 @@ class ApiClient {
if (response.statusCode == 403) { if (response.statusCode == 403) {
tryCachedLogin().then((value) { tryCachedLogin().then((value) {
if (!value.isAuthenticated) { if (!value.isAuthenticated) {
// TODO: Turn api-client into a change notifier to present login screen when logged out onLogout();
} }
}); });
} }
@ -138,7 +138,7 @@ class ApiClient {
if (response.statusCode < 300) return; if (response.statusCode < 300) return;
final error = "${switch (response.statusCode) { final error = "${switch (response.statusCode) {
429 => "Sorry, you are being rate limited.", 429 => "You are being rate limited.",
403 => "You are not authorized to do that.", 403 => "You are not authorized to do that.",
404 => "Resource not found.", 404 => "Resource not found.",
500 => "Internal server error.", 500 => "Internal server error.",

View file

@ -15,7 +15,6 @@ class SettingsClient {
final data = await _storage.read(key: _settingsKey); final data = await _storage.read(key: _settingsKey);
if (data == null) return; if (data == null) return;
_currentSettings = Settings.fromMap(jsonDecode(data)); _currentSettings = Settings.fromMap(jsonDecode(data));
} }
Future<void> changeSettings(Settings newSettings) async { Future<void> changeSettings(Settings newSettings) async {

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:contacts_plus_plus/apis/github_api.dart'; import 'package:contacts_plus_plus/apis/github_api.dart';
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart';
@ -26,14 +27,20 @@ void main() async {
Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
final settingsClient = SettingsClient(); final settingsClient = SettingsClient();
await settingsClient.loadSettings(); await settingsClient.loadSettings();
await settingsClient.changeSettings(settingsClient.currentSettings); // Save generated defaults to disk final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
try {
cachedAuth = await ApiClient.tryCachedLogin();
} catch (_) {}
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
} }
class ContactsPlusPlus extends StatefulWidget { class ContactsPlusPlus extends StatefulWidget {
const ContactsPlusPlus({required this.settingsClient, super.key}); const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key});
final SettingsClient settingsClient; final SettingsClient settingsClient;
final AuthenticationData cachedAuthentication;
@override @override
State<ContactsPlusPlus> createState() => _ContactsPlusPlusState(); State<ContactsPlusPlus> createState() => _ContactsPlusPlusState();
@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget {
class _ContactsPlusPlusState extends State<ContactsPlusPlus> { class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
final Typography _typography = Typography.material2021(platform: TargetPlatform.android); final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
AuthenticationData _authData = AuthenticationData.unauthenticated(); late AuthenticationData _authData = widget.cachedAuthentication;
bool _checkedForUpdate = false; bool _checkedForUpdate = false;
void showUpdateDialogOnFirstBuild(BuildContext context) { void showUpdateDialogOnFirstBuild(BuildContext context) {
@ -95,43 +102,55 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClientHolder( return Phoenix(
settingsClient: widget.settingsClient, child: Builder(
authenticationData: _authData, builder: (context) {
child: DynamicColorBuilder( return ClientHolder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( settingsClient: widget.settingsClient,
debugShowCheckedModeBanner: false, authenticationData: _authData,
title: 'Contacts++', onLogout: () {
theme: ThemeData( setState(() {
useMaterial3: true, _authData = AuthenticationData.unauthenticated();
textTheme: _typography.white, });
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), Phoenix.rebirth(context);
), },
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder child: DynamicColorBuilder(
builder: (context) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
showUpdateDialogOnFirstBuild(context); debugShowCheckedModeBanner: false,
final clientHolder = ClientHolder.of(context); title: 'Contacts++',
return _authData.isAuthenticated ? theme: ThemeData(
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. useMaterial3: true,
create: (context) => textTheme: _typography.white,
MessagingClient( colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
apiClient: clientHolder.apiClient, ),
notificationClient: clientHolder.notificationClient, home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
), builder: (context) {
child: const FriendsList(), showUpdateDialogOnFirstBuild(context);
) : final clientHolder = ClientHolder.of(context);
LoginScreen( return _authData.isAuthenticated ?
onLoginSuccessful: (AuthenticationData authData) async { ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
if (authData.isAuthenticated) { create: (context) =>
setState(() { MessagingClient(
_authData = authData; apiClient: clientHolder.apiClient,
}); notificationClient: clientHolder.notificationClient,
),
child: const FriendsList(),
) :
LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) {
setState(() {
_authData = authData;
});
}
},
);
} }
}, )
); ),
} ),
) );
), }
), ),
); );
} }

View file

@ -19,12 +19,7 @@ class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _totpController = TextEditingController(); final TextEditingController _totpController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
if (value.isAuthenticated) {
await loginSuccessful(value);
}
return value;
});
late final FocusNode _passwordFocusNode; late final FocusNode _passwordFocusNode;
late final FocusNode _totpFocusNode; late final FocusNode _totpFocusNode;
@ -150,102 +145,94 @@ class _LoginScreenState extends State<LoginScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text("Contacts++"), title: const Text("Contacts++"),
), ),
body: FutureBuilder( body: Builder(
future: _cachedLoginFuture, builder: (context) {
builder: (context, snapshot) { return ListView(
if (snapshot.hasData || snapshot.hasError) { controller: _scrollController,
final authData = snapshot.data; children: [
if (authData?.isAuthenticated ?? false) { Padding(
return const SizedBox.shrink(); padding: const EdgeInsets.symmetric(vertical: 64),
} child: Center(
return ListView( child: Text("Sign In", style: Theme
controller: _scrollController, .of(context)
children: [ .textTheme
Padding( .headlineMedium),
padding: const EdgeInsets.symmetric(vertical: 64), ),
child: Center( ),
child: Text("Sign In", style: Theme Padding(
.of(context) padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
.textTheme child: TextField(
.headlineMedium), autofocus: true,
controller: _usernameController,
onEditingComplete: () => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: 'Username',
), ),
), ),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
onEditingComplete: submit,
obscureText: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32)
),
labelText: 'Password',
),
),
),
if (_needsTotp)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField( child: TextField(
autofocus: true, controller: _totpController,
controller: _usernameController, focusNode: _totpFocusNode,
onEditingComplete: () => _passwordFocusNode.requestFocus(), onEditingComplete: submit,
obscureText: false,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
), ),
labelText: 'Username', labelText: '2FA Code',
), ),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.only(top: 16),
child: TextField( child: _isLoading ?
controller: _passwordController, const Center(child: CircularProgressIndicator()) :
focusNode: _passwordFocusNode, TextButton.icon(
onEditingComplete: submit, onPressed: submit,
obscureText: true, icon: const Icon(Icons.login),
decoration: InputDecoration( label: const Text("Login"),
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32)
),
labelText: 'Password',
),
),
), ),
if (_needsTotp) ),
Padding( Center(
child: AnimatedOpacity(
opacity: _errorOpacity,
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField( child: Text(_error, style: Theme
controller: _totpController, .of(context)
focusNode: _totpFocusNode, .textTheme
onEditingComplete: submit, .labelMedium
obscureText: false, ?.copyWith(color: Colors.red)),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: '2FA Code',
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: _isLoading ?
const Center(child: CircularProgressIndicator()) :
TextButton.icon(
onPressed: submit,
icon: const Icon(Icons.login),
label: const Text("Login"),
), ),
), ),
Center( )
child: AnimatedOpacity( ],
opacity: _errorOpacity, );
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: Text(_error, style: Theme
.of(context)
.textTheme
.labelMedium
?.copyWith(color: Colors.red)),
),
),
)
],
);
}
return const LinearProgressIndicator();
} }
), ),
); );

View file

@ -44,7 +44,7 @@ class SettingsPage extends StatelessWidget {
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ClientHolder.of(context).apiClient.logout(context); await ClientHolder.of(context).apiClient.logout();
}, },
child: const Text("Yes"), child: const Text("Yes"),
), ),