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';
|
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";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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'
|
||||||
|
|
Loading…
Reference in a new issue