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.
This commit is contained in:
Linty
2025-11-17 21:43:14 +01:00
parent 409d89af4c
commit e0a2a0ba2b
5 changed files with 194 additions and 45 deletions
+40
View File
@@ -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 = '<p style="margin-top: 20px;">'.l10n('Hello %s,', $username).'</p>';
$message .= '<p style="margin-bottom: 20px;">'.l10n('Your password was successfully reset').'.</p>';
$message .= '<p>';
$message .= l10n('If this wasn\'t you, please change your password immediately or contact your webmaster.');
$message .= '</p>';
if ($nb_of_apikeys > 0)
{
$message .= '<p style="margin: 20px 0;">';
$message .= l10n(
'If you changed your password because you think it was stolen, we recommend revoking your %d API keys <a href="%s">in your profile</a>.',
$nb_of_apikeys,
$profile_url
);
$message .= '</p>';
}
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');
?>
+25
View File
@@ -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)
*
+6
View File
@@ -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 <a href="%s">in your profile</a>.'] = 'If you changed your password because you think it was stolen, we recommend revoking your %d API keys <a href="%s">in your profile</a>.';
$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.';
+6
View File
@@ -527,3 +527,9 @@ $lang['The secret will no longer be displayed. You must copy it to continue.'] =
$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';
$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 <a href="%s">in your profile</a>.'] = '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 <a href="%s">sur votre profil</a>.';
$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.';
+116 -44
View File
@@ -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'][] = '<a href="'.get_root_url().'identification.php">'.l10n('Login').'</a>';
@@ -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())