Redesign login screen and add 2FA support
This commit is contained in:
parent
639cdebf4e
commit
6f99015a1c
3 changed files with 141 additions and 75 deletions
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -57,7 +63,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
try {
|
||||
final authData = await ApiClient.tryLogin(
|
||||
username: _usernameController.text,
|
||||
password: _passwordController.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(() {
|
||||
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"),
|
||||
)
|
||||
|
@ -132,25 +158,29 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
if (authData?.isAuthenticated ?? false) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
return ListView(
|
||||
controller: _scrollController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||
child: Center(
|
||||
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(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
labelText: 'Username',
|
||||
),
|
||||
),
|
||||
|
@ -162,26 +192,54 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
focusNode: _passwordFocusNode,
|
||||
onEditingComplete: submit,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(32)
|
||||
),
|
||||
labelText: 'Password',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
const CircularProgressIndicator()
|
||||
else
|
||||
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"),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
),
|
||||
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)),
|
||||
child: Text(_error, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Colors.red)),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -192,5 +250,4 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue