From 065200488061eeee06190854bb2c14f9f929e862 Mon Sep 17 00:00:00 2001 From: corentin-soriano <corentin+github@soriano.app> Date: Fri, 15 Nov 2024 11:45:12 +0100 Subject: [PATCH] Add strong user password policy. --- includes/core/load.js.php | 2 +- includes/language/english.php | 1 + includes/language/french.php | 1 + index.php | 4 ++- sources/main.functions.php | 68 +++++++++++++++++++++++++++++++++++ sources/main.queries.php | 25 ++++++++++++- 6 files changed, 98 insertions(+), 3 deletions(-) diff --git a/includes/core/load.js.php b/includes/core/load.js.php index 112638609..224e0f379 100755 --- a/includes/core/load.js.php +++ b/includes/core/load.js.php @@ -1003,9 +1003,9 @@ function() { $('#dialog-user-change-password-do, #dialog-user-change-password-close').attr('disabled', 'disabled'); data = { - 'user_id': store.get('teampassUser').user_id, 'old_password': $('#profile-current-password').val(), 'new_password': $('#profile-password').val(), + 'new_password_confirm': $('#profile-password-confirm').val(), } if (debugJavascript === true) console.log(data); diff --git a/includes/language/english.php b/includes/language/english.php index fb5237ff0..c6ad0c2c2 100755 --- a/includes/language/english.php +++ b/includes/language/english.php @@ -318,6 +318,7 @@ 'settings_ldap_additional_user_dn_tip' => 'This value is used in addition to the base DN when searching and loading users. If no value is supplied, the subtree search will start from the base DN. Examples: ou=Users ; cn=users', 'settings_ldap_additional_user_dn' => 'Additional User DN', 'ldap_user_has_changed_his_password' => 'Your authentication password has been changed in your AD since you last get logged in in Teampass. We need to adapt your encryption key. Please provide your previous password and the current one.', + 'user_password_policy_tip' => 'The new password must:<br/> - Be different from the previous one<br/> - Contain at least 10 characters<br/> - Contain at least one uppercase letter and one lowercase letter<br/> - Contain at least one number or special character<br/> - Not contain your name, first name, username, or email.', 'provide_your_previous_password' => 'Your previous password', 'admin_change_user_password_info' => 'This operation will reset the selected user current password.', 'sending_email_message' => 'Now sending email to user, please wait', diff --git a/includes/language/french.php b/includes/language/french.php index 881d49064..3891dbfd2 100755 --- a/includes/language/french.php +++ b/includes/language/french.php @@ -54,6 +54,7 @@ 'settings_ldap_additional_user_dn_tip' => 'Cette valeur est utilisée en plus de la base DN lors de la recherche et du chargement des utilisateurs. Si aucune valeur n'est fournie, la recherche des sous-arbres commencera à partir de la base. Exemples: ou=Users ; cn=users', 'settings_ldap_additional_user_dn' => 'Identifiant DN utilisateur supplémentaire', 'ldap_user_has_changed_his_password' => 'Votre mot de passe d'authentification a été changé dans votre annuaire LDAP depuis votre dernière connexion à Teampass. Nous devons adapter votre clé de chiffrement. Veuillez fournir votre précédent mot de passe et l'actuel.', + 'user_password_policy_tip' => 'Le nouveau mot de passe doit :<br/> - Etre différent du précédent<br/> - Contenir au moins 10 caractères<br/> - Contenir au moins une lettre en majuscule et une en minuscule<br/> - Contenir au moins un chiffre ou caractère spécial<br/> - Ne pas contenir votre nom/prénom/identifiant/mail.', 'provide_your_previous_password' => 'Votre précédent mot de passe', 'admin_change_user_password_info' => 'Cette opération réinitialisera le mot de passe actuel de l'utilisateur sélectionné.', 'sending_email_message' => 'Envoi de l'email à l'utilisateur, veuillez patienter', diff --git a/index.php b/index.php index c024508ba..1d9041b27 100755 --- a/index.php +++ b/index.php @@ -742,7 +742,9 @@ <div class="card-body"> <div class="row"> <div class="col-sm-12 col-md-12"> - <div class="mb-5 alert alert-info hidden" id="dialog-user-change-password-info"> + <div class="mb-5 alert alert-info" id="dialog-user-change-password-info"> + <i class="icon fa-solid fa-info mr-2"></i> + <?php echo $lang->get('user_password_policy_tip'); ?> </div> <div class="input-group mb-3"> <div class="input-group-prepend"> diff --git a/sources/main.functions.php b/sources/main.functions.php index aa8104b34..1778967cf 100755 --- a/sources/main.functions.php +++ b/sources/main.functions.php @@ -4412,3 +4412,71 @@ function checkIdsExist(array $ids, string $tableName, string $fieldName) : array return $missingIds; // Renvoie les IDs qui n'existent pas dans la table } + +/** + * Check that a password is strong. The password needs to have at least : + * - length >= 10. + * - Uppercase and lowercase chars. + * - Number or special char. + * - Not contain username, name or mail part. + * - Different from previous password. + * + * @param string $password - Password to ckeck. + * @return bool - true if the password is strong, false otherwise. + */ +function isPasswordStrong($password) { + $session = SessionManager::getSession(); + + // Password can't contain login, name or lastname + $forbiddenWords = [ + $session->get('user-login'), + $session->get('user-name'), + $session->get('user-lastname'), + ]; + + // Cut out the email + if ($email = $session->get('user-email')) { + $emailParts = explode('@', $email); + + if (count($emailParts) === 2) { + // Mail username (removed @domain.tld) + $forbiddenWords[] = $emailParts[0]; + + // Organisation name (removed username@ and .tld) + $domain = explode('.', $emailParts[1]); + if (count($domain) > 1) + $forbiddenWords[] = $domain[0]; + } + } + + // Search forbidden words in password + foreach ($forbiddenWords as $word) { + if (empty($word)) + continue; + + // Stop if forbidden word found in password + if (stripos($password, $word) !== false) + return false; + } + + // Get password complexity + $length = strlen($password); + $hasUppercase = preg_match('/[A-Z]/', $password); + $hasLowercase = preg_match('/[a-z]/', $password); + $hasNumber = preg_match('/[0-9]/', $password); + $hasSpecialChar = preg_match('/[\W_]/', $password); + + // Get current user hash + $userHash = DB::queryFirstRow( + "SELECT pw FROM " . prefixtable('users') . " WHERE id = %d;", + $session->get('user-id') + )['pw']; + + $passwordManager = new PasswordManager(); + + return $length >= 8 + && $hasUppercase + && $hasLowercase + && ($hasNumber || $hasSpecialChar) + && !$passwordManager->verifyPassword($userHash, $password); +} diff --git a/sources/main.queries.php b/sources/main.queries.php index 60b4ba1f9..81f53c432 100755 --- a/sources/main.queries.php +++ b/sources/main.queries.php @@ -229,6 +229,29 @@ function passwordHandler(string $post_type, /*php8 array|null|string*/ $dataRece * Change user's authentication password */ case 'change_user_auth_password'://action_password + + // Check new password and confirm match server side + if ($dataReceived['new_password'] !== $dataReceived['new_password_confirm']) { + return prepareExchangedData( + array( + 'error' => true, + 'message' => $lang->get('error_bad_credentials'), + ), + 'encode' + ); + } + + // Check if new password is strong + if (!isPasswordStrong($dataReceived['new_password'])) { + return prepareExchangedData( + array( + 'error' => true, + 'message' => $lang->get('complexity_level_not_reached'), + ), + 'encode' + ); + } + return changeUserAuthenticationPassword( (int) $session->get('user-id'), (string) filter_var($dataReceived['old_password'], FILTER_SANITIZE_FULL_SPECIAL_CHARS), @@ -3109,7 +3132,7 @@ function changeUserAuthenticationPassword( { $session = SessionManager::getSession(); $lang = new Language($session->get('user-language') ?? 'english'); - + if (isUserIdValid($post_user_id) === true) { // Get user info $userData = DB::queryFirstRow(