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';
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";
}

View file

@ -17,6 +17,8 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _totpController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
if (value.isAuthenticated) {
await loginSuccessful(value);
@ -24,9 +26,11 @@ class _LoginScreenState extends State<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
});
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<LoginScreen> {
_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<void> 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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
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();
}
),
);
}
}

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
# 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'