From 6f99015a1c71e596bcc96cabcb539756787a2734 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 7 May 2023 17:25:00 +0200 Subject: [PATCH] Redesign login screen and add 2FA support --- lib/clients/api_client.dart | 15 ++- lib/widgets/login_screen.dart | 199 ++++++++++++++++++++++------------ pubspec.yaml | 2 +- 3 files changed, 141 insertions(+), 75 deletions(-) diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index e9e54ab..825281c 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart'; import '../config.dart'; class ApiClient { + static const String totpKey = "TOTP"; static const String userIdKey = "userId"; static const String machineIdKey = "machineId"; static const String tokenKey = "token"; @@ -28,7 +29,8 @@ class ApiClient { required String username, required String password, bool rememberMe=true, - bool rememberPass=false + bool rememberPass=false, + String? oneTimePad, }) async { final body = { "username": username, @@ -38,8 +40,15 @@ class ApiClient { }; final response = await http.post( buildFullUri("/UserSessions"), - headers: {"Content-Type": "application/json"}, - body: jsonEncode(body)); + headers: { + "Content-Type": "application/json", + if (oneTimePad != null) totpKey : oneTimePad, + }, + body: jsonEncode(body), + ); + if (response.statusCode == 403 && response.body == totpKey) { + throw totpKey; + } if (response.statusCode == 400) { throw "Invalid Credentials"; } diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index 6fda6d2..753ed18 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -17,6 +17,8 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { final TextEditingController _usernameController = TextEditingController(); 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); @@ -24,9 +26,11 @@ class _LoginScreenState extends State { return value; }); late final FocusNode _passwordFocusNode; + late final FocusNode _totpFocusNode; bool _isLoading = false; String _error = ""; + bool _needsTotp = false; double get _errorOpacity => _error.isEmpty ? 0.0 : 1.0; @@ -35,11 +39,13 @@ class _LoginScreenState extends State { super.initState(); _usernameController.text = widget.cachedUsername ?? ""; _passwordFocusNode = FocusNode(); + _totpFocusNode = FocusNode(); } @override void dispose() { _passwordFocusNode.dispose(); + _totpFocusNode.dispose(); super.dispose(); } @@ -56,8 +62,9 @@ class _LoginScreenState extends State { }); try { final authData = await ApiClient.tryLogin( - username: _usernameController.text, - password: _passwordController.text + username: _usernameController.text, + password: _passwordController.text, + oneTimePad: _totpController.text.isEmpty ? null : _totpController.text, ); if (!authData.isAuthenticated) { setState(() { @@ -71,17 +78,34 @@ class _LoginScreenState extends State { _isLoading = false; }); await loginSuccessful(authData); - } catch (e, s) { - FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + } catch (e) { setState(() { - _error = "Login unsuccessful: $e."; + if (e == ApiClient.totpKey) { + if (_needsTotp == false) { + _error = "Please enter your 2FA-Code"; + _totpFocusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _scrollController.animateTo(_scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCirc + ); + }); + } else { + _error = "The given 2FA code is not valid."; + } + _needsTotp = true; + } else { + _error = "Login unsuccessful: $e."; + } _isLoading = false; }); } } Future loginSuccessful(AuthenticationData authData) async { - final settingsClient = ClientHolder.of(context).settingsClient; + final settingsClient = ClientHolder + .of(context) + .settingsClient; final notificationManager = FlutterLocalNotificationsPlugin(); if (settingsClient.currentSettings.notificationsDenied.value == null) { if (context.mounted) { @@ -95,7 +119,8 @@ class _LoginScreenState extends State { TextButton( onPressed: () async { Navigator.of(context).pop(); - await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: true)); + await settingsClient.changeSettings( + settingsClient.currentSettings.copyWith(notificationsDenied: true)); }, child: const Text("No"), ), @@ -105,7 +130,8 @@ class _LoginScreenState extends State { final requestResult = await notificationManager.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.requestPermission(); - await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: requestResult == null ? false : !requestResult)); + await settingsClient.changeSettings(settingsClient.currentSettings.copyWith( + notificationsDenied: requestResult == null ? false : !requestResult)); }, child: const Text("Yes"), ) @@ -125,72 +151,103 @@ class _LoginScreenState extends State { 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(); + 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), + ), + ), + 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( + 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"), + ), + ), + 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 Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 32), - child: Text("Sign In", style: Theme - .of(context) - .textTheme - .headlineMedium), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: TextField( - autofocus: true, - controller: _usernameController, - onEditingComplete: () => _passwordFocusNode.requestFocus(), - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Username', - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: TextField( - controller: _passwordController, - focusNode: _passwordFocusNode, - onEditingComplete: submit, - obscureText: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Password', - ), - ), - ), - if (_isLoading) - const CircularProgressIndicator() - else - TextButton.icon( - onPressed: submit, - icon: const Icon(Icons.login), - label: const Text("Login"), - ), - 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(); } - return const LinearProgressIndicator(); - } ), ); } - } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 11ad770..28dc2d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.0+1 +version: 1.1.1+1 environment: sdk: '>=2.19.6 <3.0.0'