From 277c08048de81f473bfd7143d6e268aaa3295076 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Thu, 18 Feb 2021 18:20:50 +0100 Subject: [PATCH] Implement password policy for account creation --- lib/helpers/server_config_helper.dart | 14 ++ lib/models/server_config.dart | 34 +++- lib/ui/routes/create_account_route.dart | 33 ++-- lib/ui/widgets/new_password_input_widget.dart | 164 ++++++++++++++++++ 4 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 lib/ui/widgets/new_password_input_widget.dart diff --git a/lib/helpers/server_config_helper.dart b/lib/helpers/server_config_helper.dart index fb1a00c..1f3da80 100644 --- a/lib/helpers/server_config_helper.dart +++ b/lib/helpers/server_config_helper.dart @@ -16,9 +16,23 @@ class ServerConfigurationHelper { (await APIRequest.withoutLogin("server/config").execWithThrow()) .getObject(); + final passwordPolicy = response["password_policy"]; final dataConservationPolicy = response["data_conservation_policy"]; _config = ServerConfig( + passwordPolicy: PasswordPolicy( + allowMailInPassword: passwordPolicy["allow_email_in_password"], + allowNameInPassword: passwordPolicy["allow_name_in_password"], + minPasswordLength: passwordPolicy["min_password_length"], + minNumberUpperCaseLetters: + passwordPolicy["min_number_upper_case_letters"], + minNumberLowerCaseLetters: + passwordPolicy["min_number_lower_case_letters"], + minNumberDigits: passwordPolicy["min_number_digits"], + minNumberSpecialCharacters: + passwordPolicy["min_number_special_characters"], + minCategoriesPresence: passwordPolicy["min_categories_presence"], + ), dataConservationPolicy: ServerDataConservationPolicy( minInactiveAccountLifetime: dataConservationPolicy["min_inactive_account_lifetime"], diff --git a/lib/models/server_config.dart b/lib/models/server_config.dart index e43ca9f..bb65dd1 100644 --- a/lib/models/server_config.dart +++ b/lib/models/server_config.dart @@ -4,6 +4,35 @@ import 'package:flutter/widgets.dart'; /// /// @author Pierre Hubert +class PasswordPolicy { + final bool allowMailInPassword; + final bool allowNameInPassword; + final int minPasswordLength; + final int minNumberUpperCaseLetters; + final int minNumberLowerCaseLetters; + final int minNumberDigits; + final int minNumberSpecialCharacters; + final int minCategoriesPresence; + + const PasswordPolicy({ + @required this.allowMailInPassword, + @required this.allowNameInPassword, + @required this.minPasswordLength, + @required this.minNumberUpperCaseLetters, + @required this.minNumberLowerCaseLetters, + @required this.minNumberDigits, + @required this.minNumberSpecialCharacters, + @required this.minCategoriesPresence, + }) : assert(allowMailInPassword != null), + assert(allowNameInPassword != null), + assert(minPasswordLength != null), + assert(minNumberUpperCaseLetters != null), + assert(minNumberLowerCaseLetters != null), + assert(minNumberDigits != null), + assert(minNumberSpecialCharacters != null), + assert(minCategoriesPresence != null); +} + class ServerDataConservationPolicy { final int minInactiveAccountLifetime; final int minNotificationLifetime; @@ -28,9 +57,12 @@ class ServerDataConservationPolicy { } class ServerConfig { + final PasswordPolicy passwordPolicy; final ServerDataConservationPolicy dataConservationPolicy; const ServerConfig({ + @required this.passwordPolicy, @required this.dataConservationPolicy, - }) : assert(dataConservationPolicy != null); + }) : assert(passwordPolicy != null), + assert(dataConservationPolicy != null); } diff --git a/lib/ui/routes/create_account_route.dart b/lib/ui/routes/create_account_route.dart index 6bdb6e0..998e7b4 100644 --- a/lib/ui/routes/create_account_route.dart +++ b/lib/ui/routes/create_account_route.dart @@ -1,6 +1,9 @@ import 'package:comunic/helpers/account_helper.dart'; +import 'package:comunic/helpers/server_config_helper.dart'; import 'package:comunic/models/config.dart'; import 'package:comunic/models/new_account.dart'; +import 'package:comunic/ui/widgets/async_screen_widget.dart'; +import 'package:comunic/ui/widgets/new_password_input_widget.dart'; import 'package:comunic/utils/input_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; import 'package:comunic/utils/ui_utils.dart'; @@ -34,7 +37,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> { final _firstNameController = TextEditingController(); final _lastNameController = TextEditingController(); final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); + final _passwordInputKey = GlobalKey(); final _verifyPasswordController = TextEditingController(); bool _acceptedTOS = false; @@ -49,10 +52,11 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> { bool get _isEmailValid => validateEmail(_emailController.text); - bool get _isPasswordValid => _passwordController.text.length > 3; + bool get _isPasswordValid => _passwordInputKey.currentState.valid; bool get _isPasswordConfirmationValid => - _passwordController.text == _verifyPasswordController.text; + _passwordInputKey.currentState != null && + _passwordInputKey.currentState.value == _verifyPasswordController.text; bool get _isFormValid => _isFirstNameValid && @@ -69,10 +73,16 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> { ? tr( "Too many accounts have been created from this IP address for now. Please try again later.") : tr( - "An error occured while creating your account. Please try again."); + "An error occurred while creating your account. Please try again."); @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => AsyncScreenWidget( + onReload: () => ServerConfigurationHelper.ensureLoaded(), + onBuild: () => _buildForm(), + errorMessage: tr("Failed to load server configuration !"), + ); + + Widget _buildForm() { if (_isCreating) return Center( child: CircularProgressIndicator(), @@ -124,15 +134,14 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> { ), // Password - _InputEntry( - controller: _passwordController, + NewPasswordInputWidget( + key: _passwordInputKey, label: tr("Password"), onEdited: _updateUI, icon: Icon(Icons.lock), - isPassword: true, - error: _showErrors && !_isPasswordValid - ? tr("Invalid password!") - : null, + email: _emailController.text, + firstName: _firstNameController.text, + lastName: _lastNameController.text, ), // Verify password @@ -206,7 +215,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> { firstName: _firstNameController.text, lastName: _lastNameController.text, email: _emailController.text, - password: _passwordController.text, + password: _passwordInputKey.currentState.value, )); setState(() { diff --git a/lib/ui/widgets/new_password_input_widget.dart b/lib/ui/widgets/new_password_input_widget.dart new file mode 100644 index 0000000..8100535 --- /dev/null +++ b/lib/ui/widgets/new_password_input_widget.dart @@ -0,0 +1,164 @@ +import 'package:comunic/helpers/server_config_helper.dart'; +import 'package:comunic/models/server_config.dart'; +import 'package:comunic/models/user.dart'; +import 'package:comunic/utils/intl_utils.dart'; +import 'package:comunic/utils/ui_utils.dart'; +import 'package:flutter/material.dart'; + +/// New password input widget +/// +/// @author Pierre Hubert + +class NewPasswordInputWidget extends StatefulWidget { + final Widget icon; + final VoidCallback onEdited; + final String label; + + final User user; + final String firstName; + final String lastName; + final String email; + + const NewPasswordInputWidget({ + Key key, + this.icon, + this.onEdited, + @required this.label, + this.user, + this.firstName, + this.lastName, + this.email, + }) : super(key: key); + + @override + NewPasswordInputWidgetState createState() => NewPasswordInputWidgetState(); +} + +class NewPasswordInputWidgetState extends State { + final TextEditingController _controller = TextEditingController(); + bool _ready = false; + + String get value => _controller.text; + + bool get valid => _ready && value.isNotEmpty && (_errorMessage ?? "").isEmpty; + + PasswordPolicy get _policy => ServerConfigurationHelper.config.passwordPolicy; + + @override + void initState() { + super.initState(); + + _init(); + } + + /// Make sure server configuration is loaded + void _init() async { + try { + await ServerConfigurationHelper.ensureLoaded(); + setState(() => _ready = true); + } catch (e, s) { + print("Failed to initialize NewPasswordInputWidget! : $e - $s"); + showSimpleSnack(context, tr("Failed to retrieve server configuration!")); + } + } + + @override + void didUpdateWidget(covariant NewPasswordInputWidget oldWidget) { + super.didUpdateWidget(oldWidget); + setState(() {}); + } + + @override + Widget build(BuildContext context) => TextField( + controller: _controller, + obscureText: true, + onChanged: (s) => _onChanged(), + decoration: InputDecoration( + errorText: _errorMessage, + errorMaxLines: 3, + icon: widget.icon, + labelText: widget.label, + ), + ); + + void _onChanged() { + setState(() {}); + if (widget.onEdited != null) widget.onEdited(); + } + + /// Generate an error message associated with current password + String get _errorMessage { + if (!_ready || value.isEmpty) return null; + + // Mandatory checks + if (!_policy.allowMailInPassword && + (widget.email ?? "").isNotEmpty && + (widget.email.toLowerCase().contains(value.toLowerCase()) || + value.toLowerCase().contains(widget.email.toLowerCase()))) { + return tr("Your password must not contains part of your email address!"); + } + + if (!_policy.allowMailInPassword && + (widget.email ?? "").isNotEmpty && + (widget.email.toLowerCase().contains(value.toLowerCase()) || + value.toLowerCase().contains(widget.email.toLowerCase()))) { + return tr("Your password must not contains part of your email address!"); + } + + if (!_policy.allowNameInPassword && + (widget.firstName ?? "").isNotEmpty && + value.toLowerCase().contains(widget.firstName.toLowerCase())) { + return tr("Your password must not contains your first name!"); + } + + if (!_policy.allowNameInPassword && + (widget.lastName ?? "").isNotEmpty && + value.toLowerCase().contains(widget.lastName.toLowerCase())) { + return tr("Your password must not contains your last name!"); + } + + if (_policy.minPasswordLength > value.length) { + return tr( + "Your password must be composed of at least %num% characters!", + args: { + "num": _policy.minPasswordLength.toString(), + }, + ); + } + + // Characteristics check + var count = 0; + + if (_hasCharacteristic(RegExp(r'[A-Z]'), _policy.minNumberUpperCaseLetters)) + count++; + + if (_hasCharacteristic(RegExp(r'[a-z]'), _policy.minNumberLowerCaseLetters)) + count++; + + if (_hasCharacteristic(RegExp(r'[0-9]'), _policy.minNumberDigits)) count++; + + if (_hasCharacteristic( + RegExp(r'[^A-Za-z0-9]'), _policy.minNumberSpecialCharacters)) count++; + + if (count >= _policy.minCategoriesPresence) return null; + + return tr( + "Your password must contains characters of at least %num% of the following categories : %upper% upper case letter, %lower% lowercase letter, %digit% digit, %special% special character.", + args: { + "num": (_policy.minCategoriesPresence == 4 + ? tr("ALL") + : _policy.minCategoriesPresence.toString()), + "upper": _policy.minNumberUpperCaseLetters.toString(), + "lower": _policy.minNumberLowerCaseLetters.toString(), + "digit": _policy.minNumberDigits.toString(), + "special": _policy.minNumberSpecialCharacters.toString(), + }, + ); + } + + bool _hasCharacteristic(RegExp exp, int requiredCount) { + if (requiredCount < 1) return true; + + return exp.allMatches(value).length >= requiredCount; + } +}