Redesign login screen and add 2FA support

This commit is contained in:
Nutcake 2023-05-07 17:25:00 +02:00
parent 639cdebf4e
commit 6f99015a1c
3 changed files with 141 additions and 75 deletions

View file

@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
import '../config.dart'; import '../config.dart';
class ApiClient { class ApiClient {
static const String totpKey = "TOTP";
static const String userIdKey = "userId"; static const String userIdKey = "userId";
static const String machineIdKey = "machineId"; static const String machineIdKey = "machineId";
static const String tokenKey = "token"; static const String tokenKey = "token";
@ -28,7 +29,8 @@ class ApiClient {
required String username, required String username,
required String password, required String password,
bool rememberMe=true, bool rememberMe=true,
bool rememberPass=false bool rememberPass=false,
String? oneTimePad,
}) async { }) async {
final body = { final body = {
"username": username, "username": username,
@ -38,8 +40,15 @@ class ApiClient {
}; };
final response = await http.post( final response = await http.post(
buildFullUri("/UserSessions"), buildFullUri("/UserSessions"),
headers: {"Content-Type": "application/json"}, headers: {
body: jsonEncode(body)); "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) { if (response.statusCode == 400) {
throw "Invalid Credentials"; throw "Invalid Credentials";
} }

View file

@ -17,6 +17,8 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _totpController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async { late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
if (value.isAuthenticated) { if (value.isAuthenticated) {
await loginSuccessful(value); await loginSuccessful(value);
@ -24,9 +26,11 @@ class _LoginScreenState extends State<LoginScreen> {
return value; return value;
}); });
late final FocusNode _passwordFocusNode; late final FocusNode _passwordFocusNode;
late final FocusNode _totpFocusNode;
bool _isLoading = false; bool _isLoading = false;
String _error = ""; String _error = "";
bool _needsTotp = false;
double get _errorOpacity => _error.isEmpty ? 0.0 : 1.0; double get _errorOpacity => _error.isEmpty ? 0.0 : 1.0;
@ -35,11 +39,13 @@ class _LoginScreenState extends State<LoginScreen> {
super.initState(); super.initState();
_usernameController.text = widget.cachedUsername ?? ""; _usernameController.text = widget.cachedUsername ?? "";
_passwordFocusNode = FocusNode(); _passwordFocusNode = FocusNode();
_totpFocusNode = FocusNode();
} }
@override @override
void dispose() { void dispose() {
_passwordFocusNode.dispose(); _passwordFocusNode.dispose();
_totpFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -56,8 +62,9 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
try { try {
final authData = await ApiClient.tryLogin( final authData = await ApiClient.tryLogin(
username: _usernameController.text, username: _usernameController.text,
password: _passwordController.text password: _passwordController.text,
oneTimePad: _totpController.text.isEmpty ? null : _totpController.text,
); );
if (!authData.isAuthenticated) { if (!authData.isAuthenticated) {
setState(() { setState(() {
@ -71,17 +78,34 @@ class _LoginScreenState extends State<LoginScreen> {
_isLoading = false; _isLoading = false;
}); });
await loginSuccessful(authData); await loginSuccessful(authData);
} catch (e, s) { } catch (e) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
setState(() { 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; _isLoading = false;
}); });
} }
} }
Future<void> loginSuccessful(AuthenticationData authData) async { Future<void> loginSuccessful(AuthenticationData authData) async {
final settingsClient = ClientHolder.of(context).settingsClient; final settingsClient = ClientHolder
.of(context)
.settingsClient;
final notificationManager = FlutterLocalNotificationsPlugin(); final notificationManager = FlutterLocalNotificationsPlugin();
if (settingsClient.currentSettings.notificationsDenied.value == null) { if (settingsClient.currentSettings.notificationsDenied.value == null) {
if (context.mounted) { if (context.mounted) {
@ -95,7 +119,8 @@ class _LoginScreenState extends State<LoginScreen> {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: true)); await settingsClient.changeSettings(
settingsClient.currentSettings.copyWith(notificationsDenied: true));
}, },
child: const Text("No"), child: const Text("No"),
), ),
@ -105,7 +130,8 @@ class _LoginScreenState extends State<LoginScreen> {
final requestResult = await notificationManager.resolvePlatformSpecificImplementation< final requestResult = await notificationManager.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin>()
?.requestPermission(); ?.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"), child: const Text("Yes"),
) )
@ -125,72 +151,103 @@ class _LoginScreenState extends State<LoginScreen> {
title: const Text("Contacts++"), title: const Text("Contacts++"),
), ),
body: FutureBuilder( body: FutureBuilder(
future: _cachedLoginFuture, future: _cachedLoginFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData || snapshot.hasError) { if (snapshot.hasData || snapshot.hasError) {
final authData = snapshot.data; final authData = snapshot.data;
if (authData?.isAuthenticated ?? false) { if (authData?.isAuthenticated ?? false) {
return const SizedBox.shrink(); 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( return const LinearProgressIndicator();
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();
}
), ),
); );
} }
} }

View file

@ -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 # 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 # 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. # 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: environment:
sdk: '>=2.19.6 <3.0.0' sdk: '>=2.19.6 <3.0.0'