From 8fc2a2ae63d7f0435cf5182f2202147c2db490b4 Mon Sep 17 00:00:00 2001 From: Sebastian Lenz Date: Tue, 27 May 2025 16:41:32 +0200 Subject: [PATCH 1/3] Add a password reset cooldown to prevent the possibility of exploiting the password reset function --- src/config/GeneralConfig.php | 35 +++++++++++++++++++++++++++++++++++ src/services/Users.php | 16 ++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/config/GeneralConfig.php b/src/config/GeneralConfig.php index 9654c10756c..534be2383d9 100644 --- a/src/config/GeneralConfig.php +++ b/src/config/GeneralConfig.php @@ -2026,6 +2026,20 @@ class GeneralConfig extends BaseConfig */ public string $partialTemplatesPath = '_partials'; + /** + * @var int The duration in seconds of the cooldown timer after a reset password mail has been sent. + * + * Prevents other from exploiting the reset password functionality by applying a phase within it is not possible to + * trigger another password reset mail. Set the duration to zero to disable the cooldown. + * + * ::: code + * ```php Static Config + * ->passwordResetCooldownDuration(300) + * ``` + * ::: + */ + public int $passwordResetCooldownDuration = 60; + /** * @var string|null The query string param that Craft will check when determining the request’s path. * @@ -5553,6 +5567,27 @@ public function pageTrigger(string $value): self return $this; } + + /** + * The duration in seconds of the cooldown timer after a reset password mail has been sent. + * + * Prevents other from exploiting the reset password functionality by applying a phase within it is not possible to + * trigger another password reset mail. Set the duration to zero to disable the cooldown. + * + * ```php + * ->passwordResetCooldownDuration(300) + * ``` + * + * @param int $value + * @return self + * @see $passwordResetCooldownDuration + */ + public function passwordResetCooldownDuration(int $value): self + { + $this->passwordResetCooldownDuration = $value; + return $this; + } + /** * The path within the `templates` folder where element partial templates will live. * diff --git a/src/services/Users.php b/src/services/Users.php index 48adf33716a..74bc1ee90ec 100644 --- a/src/services/Users.php +++ b/src/services/Users.php @@ -482,9 +482,25 @@ public function sendNewEmailVerifyEmail(User $user): bool * @param User $user The user to send the forgot password email to. * @return bool Whether the email was sent successfully. * @throws InvalidElementException if the user doesn't validate + * @throws UserNotFoundException if the user is invalid */ public function sendPasswordResetEmail(User $user): bool { + $cooldown = Craft::$app->getConfig()->getGeneral()->passwordResetCooldownDuration; + if ($cooldown > 0) { + $userRecord = $this->_getUserRecordById($user->id); + $issuedAtRaw = $userRecord->verificationCodeIssuedDate; + + if ($issuedAtRaw) { + $issuedAt = new DateTime($issuedAtRaw, new DateTimeZone('UTC')); + $elapsed = time() - $issuedAt->getTimestamp(); + + if ($elapsed < $cooldown) { + throw new \Exception('Password reset cooldown active'); + } + } + } + $url = $this->getPasswordResetUrl($user); return Craft::$app->getMailer() From f8e75bf1adedb2e784eb660ea0cec11a85bb73ed Mon Sep 17 00:00:00 2001 From: Sebastian Lenz Date: Tue, 27 May 2025 16:54:55 +0200 Subject: [PATCH 2/3] Fix code format --- src/config/GeneralConfig.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/GeneralConfig.php b/src/config/GeneralConfig.php index 534be2383d9..24b2ad4681c 100644 --- a/src/config/GeneralConfig.php +++ b/src/config/GeneralConfig.php @@ -5584,8 +5584,8 @@ public function pageTrigger(string $value): self */ public function passwordResetCooldownDuration(int $value): self { - $this->passwordResetCooldownDuration = $value; - return $this; + $this->passwordResetCooldownDuration = $value; + return $this; } /** From 34363df9ba9f7554d1369717675757d26f34b179 Mon Sep 17 00:00:00 2001 From: Sebastian Lenz Date: Wed, 28 May 2025 11:18:19 +0200 Subject: [PATCH 3/3] Do not throw an exception when a password reset cooldown is active --- src/services/Users.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/services/Users.php b/src/services/Users.php index 74bc1ee90ec..2c2fde4d267 100644 --- a/src/services/Users.php +++ b/src/services/Users.php @@ -482,21 +482,23 @@ public function sendNewEmailVerifyEmail(User $user): bool * @param User $user The user to send the forgot password email to. * @return bool Whether the email was sent successfully. * @throws InvalidElementException if the user doesn't validate - * @throws UserNotFoundException if the user is invalid */ public function sendPasswordResetEmail(User $user): bool { $cooldown = Craft::$app->getConfig()->getGeneral()->passwordResetCooldownDuration; if ($cooldown > 0) { - $userRecord = $this->_getUserRecordById($user->id); - $issuedAtRaw = $userRecord->verificationCodeIssuedDate; + try { + $userRecord = $this->_getUserRecordById($user->id); + $issuedAtRaw = $userRecord->verificationCodeIssuedDate; + $issuedAt = $issuedAtRaw ? new DateTime($issuedAtRaw, new DateTimeZone('UTC')) : null; + } catch (\Throwable) { + return false; + } - if ($issuedAtRaw) { - $issuedAt = new DateTime($issuedAtRaw, new DateTimeZone('UTC')); + if ($issuedAt) { $elapsed = time() - $issuedAt->getTimestamp(); - if ($elapsed < $cooldown) { - throw new \Exception('Password reset cooldown active'); + return false; } } }