From adfb5a2198a37409762e3ede4430366249cd7ceb Mon Sep 17 00:00:00 2001 From: Aaron van Geffen Date: Tue, 5 Nov 2024 17:19:59 +0100 Subject: [PATCH] ResetPassword: add time-out to password resets; prevent repeated mails --- controllers/ResetPassword.php | 22 ++++++++++++++++ migrations/2024-11-05.sql | 2 ++ models/Authentication.php | 48 ++++++++++++++++++++++++++++++++++- models/User.php | 1 + 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 migrations/2024-11-05.sql diff --git a/controllers/ResetPassword.php b/controllers/ResetPassword.php index dc4c806..cd0d56e 100644 --- a/controllers/ResetPassword.php +++ b/controllers/ResetPassword.php @@ -37,6 +37,24 @@ class ResetPassword extends HTMLController return; } + if (Authentication::getResetTimeOut($user->getUserId()) > 0) + { + // Update the reset time-out to prevent hammering + $resetTimeOut = Authentication::updateResetTimeOut($user->getUserId()); + + // Present it to the user in a readable way + if ($resetTimeOut > 3600) + $timeOut = sprintf('%d hours', ceil($resetTimeOut / 3600)); + elseif ($resetTimeOut > 60) + $timeOut = sprintf('%d minutes', ceil($resetTimeOut / 60)); + else + $timeOut = sprintf('%d seconds', $resetTimeOut); + + $form->adopt(new Alert('Password reset token already sent', 'We already sent a password reset token to this email address recently. ' . + 'If no email was received, please wait ' . $timeOut . ' to try again.', 'error')); + return; + } + Authentication::setResetKey($user->getUserId()); Email::resetMail($user->getUserId()); @@ -76,6 +94,10 @@ class ResetPassword extends HTMLController if (empty($missing)) { Authentication::updatePassword($user->getUserId(), Authentication::computeHash($_POST['password1'])); + + // Consume token, ensuring it isn't used again + Authentication::consumeResetKey($user->getUserId()); + $_SESSION['login_msg'] = ['Your password has been reset', 'You can now use the form below to log in to your account.', 'success']; header('Location: ' . BASEURL . '/login/'); exit; diff --git a/migrations/2024-11-05.sql b/migrations/2024-11-05.sql new file mode 100644 index 0000000..ee37d8f --- /dev/null +++ b/migrations/2024-11-05.sql @@ -0,0 +1,2 @@ +/* Add time-out to password reset keys, and prevent repeated mails */ +ALTER TABLE `users` ADD `reset_blocked_until` INT UNSIGNED NULL AFTER `reset_key`; diff --git a/models/Authentication.php b/models/Authentication.php index 8793282..324ad91 100644 --- a/models/Authentication.php +++ b/models/Authentication.php @@ -12,6 +12,8 @@ */ class Authentication { + const DEFAULT_RESET_TIMEOUT = 30; + /** * Checks a password for a given username against the database. */ @@ -57,6 +59,27 @@ class Authentication return $hash; } + public static function consumeResetKey($id_user) + { + return Registry::get('db')->query(' + UPDATE users + SET reset_key = NULL, + reset_blocked_until = NULL + WHERE id_user = {int:id_user}', + ['id_user' => $id_user]); + } + + public static function getResetTimeOut($id_user) + { + $resetTime = Registry::get('db')->queryValue(' + SELECT reset_blocked_until + FROM users + WHERE id_user = {int:id_user}', + ['id_user' => $id_user]); + + return max(0, $resetTime - time()); + } + /** * Verifies whether the user is currently logged in. */ @@ -92,7 +115,8 @@ class Authentication { return Registry::get('db')->query(' UPDATE users - SET reset_key = {string:key} + SET reset_key = {string:key}, + reset_blocked_until = UNIX_TIMESTAMP() + ' . static::DEFAULT_RESET_TIMEOUT . ' WHERE id_user = {int:id}', [ 'id' => $id_user, @@ -117,4 +141,26 @@ class Authentication 'blank' => '', ]); } + + public static function updateResetTimeOut($id_user) + { + $currentResetTimeOut = static::getResetTimeOut($id_user); + + // New timeout: between 30 seconds, double the current timeout, and a full day + $newResetTimeOut = min(max(static::DEFAULT_RESET_TIMEOUT, $currentResetTimeOut * 2), 60 * 60 * 24); + + $success = Registry::get('db')->query(' + UPDATE users + SET reset_blocked_until = {int:new_time_out} + WHERE id_user = {int:id_user}', + [ + 'id_user' => $id_user, + 'new_time_out' => time() + $newResetTimeOut, + ]); + + if (!$success) + throw new UnexpectedValueException('Could not set password reset timeout!'); + + return $newResetTimeOut; + } } diff --git a/models/User.php b/models/User.php index b69790d..a26241d 100644 --- a/models/User.php +++ b/models/User.php @@ -23,6 +23,7 @@ abstract class User protected $ip_address; protected $is_admin; protected $reset_key; + protected $reset_blocked_until; protected bool $is_logged; protected bool $is_guest;