From e0a2a0ba2bf9e739429a978e204450ea03b9857b Mon Sep 17 00:00:00 2001 From: Linty Date: Mon, 17 Nov 2025 21:43:14 +0100 Subject: [PATCH] fixes #2449 enhance password reset flow with verification and lockout Added email notification for successful password reset, improved verification code handling, and implemented account lockout after too many failed attempts. Introduced new language strings for user feedback and security messages. Refactored password reset logic to better handle guest/generic users and API key recommendations. --- include/functions_mail.inc.php | 40 +++++++++ include/functions_user.inc.php | 25 ++++++ language/en_UK/common.lang.php | 6 ++ language/fr_FR/common.lang.php | 8 +- password.php | 160 ++++++++++++++++++++++++--------- 5 files changed, 194 insertions(+), 45 deletions(-) diff --git a/include/functions_mail.inc.php b/include/functions_mail.inc.php index 926246033..6eebe62ff 100644 --- a/include/functions_mail.inc.php +++ b/include/functions_mail.inc.php @@ -1104,6 +1104,46 @@ function pwg_generate_code_verification_mail($code) ); } +/** + * Generate content mail for reset password success + * + * Return the content mail to send + * @since 16 + * @param string $code + * @return array mail content + */ +function pwg_generate_success_reset_password_mail($username, $nb_of_apikeys) +{ + global $conf; + set_make_full_url(); + $profile_url = get_root_url().'profile.php'; + + $message = '

'.l10n('Hello %s,', $username).'

'; + $message .= '

'.l10n('Your password was successfully reset').'.

'; + $message .= '

'; + $message .= l10n('If this wasn\'t you, please change your password immediately or contact your webmaster.'); + $message .= '

'; + + if ($nb_of_apikeys > 0) + { + $message .= '

'; + $message .= l10n( + 'If you changed your password because you think it was stolen, we recommend revoking your %d API keys in your profile.', + $nb_of_apikeys, + $profile_url + ); + $message .= '

'; + } + unset_make_full_url(); + + $subject = '['.$conf['gallery_title'].'] '.l10n('Your password has been reset'); + return array( + 'subject' => $subject, + 'content' => $message, + 'content_format' => 'text/html', + ); +} + trigger_notify('functions_mail_included'); ?> diff --git a/include/functions_user.inc.php b/include/functions_user.inc.php index 87c90c014..eae20fe68 100644 --- a/include/functions_user.inc.php +++ b/include/functions_user.inc.php @@ -2759,6 +2759,31 @@ SELECT return $api_keys; } +/** + * Get all available api_key + * + * @since 16 + * @param string $user_id + * @return array|false + */ +function get_available_api_key($user_id) +{ + $api_keys = get_api_key($user_id); + + if (!$api_keys) return false; + + $available = array(); + foreach($api_keys as $api_key) + { + if (!$api_key['is_expired'] && empty($api_key['revoked_on'])) + { + $available[] = $api_key; + } + } + + return count($available) > 0 ? $available : false; +} + /** * Is connected with pwg_ui (identification.php) * diff --git a/language/en_UK/common.lang.php b/language/en_UK/common.lang.php index b24c86a75..d8b751620 100644 --- a/language/en_UK/common.lang.php +++ b/language/en_UK/common.lang.php @@ -528,3 +528,9 @@ $lang['The secret will no longer be displayed. You must copy it to continue.'] = $lang['ID copied.'] = 'ID copied.'; $lang['Secret copied. Keep it in a safe place.'] = 'Secret copied. Keep it in a safe place.'; $lang['edit user preferences'] = 'edit user preferences'; +$lang['Here is your verification code:'] = 'Here is your verification code:'; +$lang['Your verification code'] = 'Your verification code'; +$lang['If this wasn\'t you, please change your password immediately or contact your webmaster.'] = 'If this wasn\'t you, please change your password immediately or contact your webmaster.'; +$lang['If you changed your password because you think it was stolen, we recommend revoking your %d API keys in your profile.'] = 'If you changed your password because you think it was stolen, we recommend revoking your %d API keys in your profile.'; +$lang['Too many attempts, please try later..'] = 'Too many attempts, please try later..'; +$lang['Verification successful! You can now choose a new password.'] = 'Verification successful! You can now choose a new password.'; diff --git a/language/fr_FR/common.lang.php b/language/fr_FR/common.lang.php index 7eeb0fc1a..0b2a74f05 100644 --- a/language/fr_FR/common.lang.php +++ b/language/fr_FR/common.lang.php @@ -526,4 +526,10 @@ $lang['Save your ID and secret'] = 'Enregistrez votre identifiant et votre secre $lang['The secret will no longer be displayed. You must copy it to continue.'] = 'Le secret ne sera plus affiché. Vous devez le copier pour continuer.'; $lang['ID copied.'] = 'Identifiant copié.'; $lang['Secret copied. Keep it in a safe place.'] = 'Secret copié. Gardez-le dans un endroit sûr.'; -$lang['edit user preferences'] = 'modifier les préférences utilisateur'; \ No newline at end of file +$lang['edit user preferences'] = 'modifier les préférences utilisateur'; +$lang['Here is your verification code:'] = 'Voici votre code de vérification :'; +$lang['Your verification code'] = 'Votre code de vérification'; +$lang['If this wasn\'t you, please change your password immediately or contact your webmaster.'] = 'Si ce n\'était pas vous, veuillez changer immédiatement votre mot de passe ou contacter votre webmaster.'; +$lang['If you changed your password because you think it was stolen, we recommend revoking your %d API keys in your profile.'] = 'Si vous avez changé votre mot de passe car vous pensez qu\'il a été volé, nous vous recommandons de révoquer vos %d clefs d\'API sur votre profil.'; +$lang['Too many attempts, please try later..'] = 'Trop de tentatives, veuillez réessayer plus tard..'; +$lang['Verification successful! You can now choose a new password.'] = 'Vérification réussie ! Vous pouvez maintenant choisir un nouveau mot de passe.'; diff --git a/password.php b/password.php index 0a9230379..5904c78b3 100644 --- a/password.php +++ b/password.php @@ -22,7 +22,7 @@ check_status(ACCESS_FREE); trigger_notify('loc_begin_password'); -check_input_parameter('action', $_GET, false, '/^(lost|reset|lost_code|lost_end|reset_end|none)$/'); +check_input_parameter('action', $_GET, false, '/^(lost|reset|lost_code|reset_end|none)$/'); // +-----------------------------------------------------------------------+ // | Functions | @@ -44,7 +44,7 @@ function process_verification_code() } // empty param - $username_or_email = trim($_POST['username_or_email']); + $username_or_email = trim($_POST['username_or_email'] ?? ''); if (empty($username_or_email)) { $page['errors']['password_form_error'] = l10n('Invalid username or email'); @@ -62,18 +62,40 @@ function process_verification_code() // when no user is found, we assign guest_id instead of stopping. // this lets the function behave identically for unknown users, // preventing username/email enumeration through timing or responses. - $is_user_founded = is_numeric($user_id); - if (!$is_user_founded) + $is_user_found = is_numeric($user_id); + if (!$is_user_found) { $user_id = $conf['guest_id']; } $userdata = getuserdata($user_id, false); + $status = $userdata['status']; + + if ($is_user_found) + { + // block early for generic or guest user because + // we don't consider theses users has sensible for username/email enumeration + if (is_a_guest($status) or is_generic($status)) + { + $page['errors']['password_form_error'] = l10n('Password reset is not allowed for this user'); + return false; + } + + // check lockout + if ( + isset($userdata['preferences']['reset_password_forbidden_until']) + and $userdata['preferences']['reset_password_forbidden_until'] > time() + ) + { + $page['errors']['password_form_error'] = l10n('Too many attempts, please try later..'); + return false; + } + } + // check if we want to skip email sending // if user is guest, generic or doesn't have email - $status = $userdata['status']; - $skip_mail = !$is_user_founded or is_a_guest($status) or is_generic($status) or empty($userdata['email']); + $skip_mail = !$is_user_found or empty($userdata['email']); // send mail with verification code to user switch_lang_to($userdata['language']); @@ -82,18 +104,13 @@ function process_verification_code() if (!$skip_mail) { $mail_send = pwg_mail($userdata['email'], $template_mail); - // pwg_activity('user', $userdata['id'], 'reset_password_code', array( - // 'ip' => $_SERVER['REMOTE_ADDR'], - // 'agent' => $_SERVER['HTTP_USER_AGENT'], - // 'is_mail_sent' => $mail_send - // )); } switch_lang_back(); $_SESSION['reset_password_code'] = [ 'secret' => $user_code['secret'], 'attempts' => 0, - 'user_id' => $is_user_founded ? $user_id : null, + 'user_id' => $is_user_found ? $user_id : null, 'created_at' => time(), 'ttl' => min($conf['password_reset_code_duration'], 900) // max 15 min ]; @@ -103,18 +120,18 @@ function process_verification_code() /** * checks the validity of input parameters, fills $page['errors'] and - * $page['infos'] and send an email with reset link + * $page['infos'] * - * @return bool (true if email was sent, false otherwise) + * @return bool (true if valid, false otherwise) */ function process_password_request() { - global $page, $conf; + global $page, $user; $state = $_SESSION['reset_password_code'] ?? null; if (!$state) { - return true; // fallback line 366 + return true; } // check expired @@ -143,10 +160,24 @@ function process_password_request() if ($_SESSION['reset_password_code']['attempts'] >= 3) { unset($_SESSION['reset_password_code']); - $page['errors']['login_page_error'] = l10n('Too many attempts'); + // lockout account for 1hour + if (!empty($state['user_id'])) + { + $save_user = $user; + $user = build_user($state['user_id'], false); + userprefs_update_param('reset_password_forbidden_until', time() + 60 * 60); + $user = $save_user; + + pwg_activity('user', $state['user_id'], 'reset_password_failure_too_many'); + } + $page['errors']['login_page_error'] = l10n('Too many attempts, please try later..'); return false; } + if (!empty($state['user_id'])) + { + pwg_activity('user', $state['user_id'], 'reset_password_failure_code'); + } $page['errors']['password_form_error'] = l10n('Invalid verification code'); return false; } @@ -161,28 +192,28 @@ function process_password_request() return false; } - $userdata = getuserdata($user_id); - $status = $userdata['status'] ?? null; + $save_user = $user; + $user = build_user($user_id, false); + userprefs_delete_param('reset_password_forbidden_until'); + + $_SESSION['valid_reset_password_code'] = array( + 'user_id' => $user_id, + 'username' => $user['username'], + 'email' => $user['email'], + 'language' => $user['language'], + ); + $status = $user['status'] ?? null; + $has_no_email = empty($user['email']); + $page['username'] = $user['username']; + $user = $save_user; // fallback check: don't send mail when user is guest, generic or doesn't have email - if (is_a_guest($status) || is_generic($status) || empty($userdata['email'])) + if (is_a_guest($status) || is_generic($status) || $has_no_email) { $page['errors']['password_form_error'] = l10n('Password reset is not allowed for this user'); return false; } - $generate_link = generate_password_link($user_id); - switch_lang_to($userdata['language']); - $email_params = pwg_generate_reset_password_mail($userdata['username'], $generate_link['password_link'], $conf['gallery_title'], $generate_link['time_validation']); - $send_email = pwg_mail($userdata['email'], $email_params); - switch_lang_back(); - - // pwg_activity('user', $userdata['id'], 'reset_password_link', array( - // 'ip' => $_SERVER['REMOTE_ADDR'], - // 'agent' => $_SERVER['HTTP_USER_AGENT'], - // 'is_mail_sent' => $send_email - // )); - return true; } @@ -253,15 +284,11 @@ function reset_password() return false; } - if (!isset($_GET['key'])) - { - $page['errors']['password_page_error'] = l10n('Invalid key'); - } - - $user_id = check_password_reset_key($_GET['key']); + $user_id = reset_password_key() ?: reset_password_code(); if (!is_numeric($user_id)) { + $page['errors']['password_form_error'] = l10n('Invalid key or code'); return false; } @@ -271,8 +298,21 @@ function reset_password() array($conf['user_fields']['id'] => $user_id) ); - deactivate_password_reset_key($user_id); - deactivate_user_auth_keys($user_id); + if (isset($_SESSION['valid_reset_password_code']) and !empty($_SESSION['valid_reset_password_code']['email'])) + { + $reset_user = $_SESSION['valid_reset_password_code']; + switch_lang_to($reset_user['language']); + + $api_keys = get_available_api_key($reset_user['user_id']); + $nb_of_apikeys = $api_keys ? count($api_keys) : 0; + $template_mail = pwg_generate_success_reset_password_mail($reset_user['username'], $nb_of_apikeys); + pwg_mail($reset_user['email'], $template_mail); + + switch_lang_back(); + } + unset($_SESSION['valid_reset_password_code']); + + pwg_activity('user', $user_id, 'reset_password_success'); $page['infos'][] = l10n('Your password has been reset'); $page['infos'][] = ''.l10n('Login').''; @@ -280,6 +320,35 @@ function reset_password() return true; } +function reset_password_key() +{ + if (!isset($_GET['key'])) + { + return false; + } + + $user_id = check_password_reset_key($_GET['key']); + + if (!is_numeric($user_id)) + { + return false; + } + + deactivate_password_reset_key($user_id); + deactivate_user_auth_keys($user_id); + return $user_id; +} + +function reset_password_code() +{ + if (!isset($_SESSION['valid_reset_password_code'])) + { + return false; + } + + return $_SESSION['valid_reset_password_code']['user_id'] ?? false; +} + // +-----------------------------------------------------------------------+ // | Process form | // +-----------------------------------------------------------------------+ @@ -300,8 +369,8 @@ if (isset($_POST['submit'])) { if (process_password_request()) { - $page['infos'][] = l10n('An email has been sent with a link to reset your password'); - $page['action'] = 'lost_end'; + $page['infos'][] = l10n('Verification successful! You can now choose a new password.'); + $page['action'] = 'reset'; } } @@ -358,9 +427,12 @@ if (!isset($page['action'])) } } -if ('reset' == $page['action'] and !isset($_GET['key']) and (is_a_guest() or is_generic())) +if ('reset' == $page['action']) { - redirect(get_gallery_home_url()); + if ( (!isset($_GET['key']) and (is_a_guest() or is_generic())) and !isset($_SESSION['valid_reset_password_code']) ) + { + redirect(get_gallery_home_url()); + } } if ('lost' == $page['action'] and !is_a_guest())