From 5561f18a2c898a9bba4844ad42538cb6983f1a59 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 18:54:21 +0200 Subject: [PATCH] Improve handling of logout and token expiration --- lib/client_holder.dart | 5 +- lib/clients/api_client.dart | 14 +-- lib/clients/settings_client.dart | 1 - lib/main.dart | 99 +++++++++++-------- lib/widgets/login_screen.dart | 157 ++++++++++++++----------------- lib/widgets/settings_page.dart | 2 +- 6 files changed, 142 insertions(+), 136 deletions(-) diff --git a/lib/client_holder.dart b/lib/client_holder.dart index cc7536f..3c1a5e0 100644 --- a/lib/client_holder.dart +++ b/lib/client_holder.dart @@ -14,8 +14,9 @@ class ClientHolder extends InheritedWidget { super.key, required AuthenticationData authenticationData, required this.settingsClient, - required super.child - }) : apiClient = ApiClient(authenticationData: authenticationData); + required super.child, + required Function() onLogout, + }) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout); static ClientHolder? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 8d0bb44..913b671 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -18,10 +18,12 @@ class ApiClient { static const String tokenKey = "token"; static const String passwordKey = "password"; - ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; + ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData; final AuthenticationData _authenticationData; final Logger _logger = Logger("API"); + // Saving the context here feels kinda cringe ngl + final Function() onLogout; AuthenticationData get authenticationData => _authenticationData; String get userId => _authenticationData.userId; @@ -103,7 +105,7 @@ class ApiClient { return AuthenticationData.unauthenticated(); } - Future logout(BuildContext context) async { + Future logout() async { const FlutterSecureStorage storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); @@ -111,9 +113,7 @@ class ApiClient { await storage.delete(key: machineIdKey); await storage.delete(key: tokenKey); await storage.delete(key: passwordKey); - if (context.mounted) { - Phoenix.rebirth(context); - } + onLogout(); } Future extendSession() async { @@ -127,7 +127,7 @@ class ApiClient { if (response.statusCode == 403) { tryCachedLogin().then((value) { 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; 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.", 404 => "Resource not found.", 500 => "Internal server error.", diff --git a/lib/clients/settings_client.dart b/lib/clients/settings_client.dart index 0f7666e..5b5df56 100644 --- a/lib/clients/settings_client.dart +++ b/lib/clients/settings_client.dart @@ -15,7 +15,6 @@ class SettingsClient { final data = await _storage.read(key: _settingsKey); if (data == null) return; _currentSettings = Settings.fromMap(jsonDecode(data)); - } Future changeSettings(Settings newSettings) async { diff --git a/lib/main.dart b/lib/main.dart index 81b5a0b..1e17929 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:contacts_plus_plus/apis/github_api.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/settings_client.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)); final settingsClient = SettingsClient(); await settingsClient.loadSettings(); - await settingsClient.changeSettings(settingsClient.currentSettings); // Save generated defaults to disk - runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); + final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault); + 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 { - const ContactsPlusPlus({required this.settingsClient, super.key}); + const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key}); final SettingsClient settingsClient; + final AuthenticationData cachedAuthentication; @override State createState() => _ContactsPlusPlusState(); @@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget { class _ContactsPlusPlusState extends State { final Typography _typography = Typography.material2021(platform: TargetPlatform.android); - AuthenticationData _authData = AuthenticationData.unauthenticated(); + late AuthenticationData _authData = widget.cachedAuthentication; bool _checkedForUpdate = false; void showUpdateDialogOnFirstBuild(BuildContext context) { @@ -95,43 +102,55 @@ class _ContactsPlusPlusState extends State { @override Widget build(BuildContext context) { - return ClientHolder( - settingsClient: widget.settingsClient, - authenticationData: _authData, - child: DynamicColorBuilder( - builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Contacts++', - theme: ThemeData( - useMaterial3: true, - textTheme: _typography.white, - colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), - ), - home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder - builder: (context) { - showUpdateDialogOnFirstBuild(context); - final clientHolder = ClientHolder.of(context); - return _authData.isAuthenticated ? - ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. - create: (context) => - MessagingClient( - apiClient: clientHolder.apiClient, - notificationClient: clientHolder.notificationClient, - ), - child: const FriendsList(), - ) : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); + return Phoenix( + child: Builder( + builder: (context) { + return ClientHolder( + settingsClient: widget.settingsClient, + authenticationData: _authData, + onLogout: () { + setState(() { + _authData = AuthenticationData.unauthenticated(); + }); + Phoenix.rebirth(context); + }, + child: DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts++', + theme: ThemeData( + useMaterial3: true, + textTheme: _typography.white, + colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), + ), + home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder + builder: (context) { + showUpdateDialogOnFirstBuild(context); + final clientHolder = ClientHolder.of(context); + return _authData.isAuthenticated ? + ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. + create: (context) => + MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + child: const FriendsList(), + ) : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) async { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ); } - }, - ); - } - ) - ), + ) + ), + ), + ); + } ), ); } diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index 753ed18..541caf9 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -19,12 +19,7 @@ class _LoginScreenState extends State { final TextEditingController _passwordController = TextEditingController(); final TextEditingController _totpController = TextEditingController(); final ScrollController _scrollController = ScrollController(); - late final Future _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async { - if (value.isAuthenticated) { - await loginSuccessful(value); - } - return value; - }); + late final FocusNode _passwordFocusNode; late final FocusNode _totpFocusNode; @@ -150,102 +145,94 @@ class _LoginScreenState extends State { appBar: AppBar( title: const Text("Contacts++"), ), - body: FutureBuilder( - future: _cachedLoginFuture, - builder: (context, snapshot) { - if (snapshot.hasData || snapshot.hasError) { - final authData = snapshot.data; - if (authData?.isAuthenticated ?? false) { - return const SizedBox.shrink(); - } - return ListView( - controller: _scrollController, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 64), - child: Center( - child: Text("Sign In", style: Theme - .of(context) - .textTheme - .headlineMedium), + body: Builder( + builder: (context) { + return ListView( + controller: _scrollController, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Center( + child: Text("Sign In", style: Theme + .of(context) + .textTheme + .headlineMedium), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + child: TextField( + 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: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( - autofocus: true, - controller: _usernameController, - onEditingComplete: () => _passwordFocusNode.requestFocus(), + controller: _totpController, + focusNode: _totpFocusNode, + onEditingComplete: submit, + obscureText: false, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), border: OutlineInputBorder( borderRadius: BorderRadius.circular(32), ), - labelText: 'Username', + labelText: '2FA Code', ), ), ), - 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', - ), - ), + 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"), ), - if (_needsTotp) - Padding( + ), + Center( + child: AnimatedOpacity( + opacity: _errorOpacity, + duration: const Duration(milliseconds: 200), + child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: TextField( - controller: _totpController, - focusNode: _totpFocusNode, - onEditingComplete: submit, - obscureText: false, - 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"), + child: Text(_error, style: Theme + .of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.red)), ), ), - 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(); + ) + ], + ); } ), ); diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index 3a77812..0d9128f 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -44,7 +44,7 @@ class SettingsPage extends StatelessWidget { TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), TextButton( onPressed: () async { - await ClientHolder.of(context).apiClient.logout(context); + await ClientHolder.of(context).apiClient.logout(); }, child: const Text("Yes"), ),