From 150d407f12e42d7db73577ca543d45c271358ce0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 16 Jun 2026 14:10:42 +0200 Subject: [PATCH] Store hashed `lostPasswordKey` in the database Store SHA-256 hash of the lost-password key in the database instead of the raw token. The plain key is only sent to the user via email; any later verification re-hashes the supplied value and compares against the stored hash via hash_equals. This limits the blast radius of a database disclosure: a read-only leak of the user table (e.g. via SQL injection or stolen backup) no longer hands the attacker working password-reset tokens. --- com.woltlab.wcf/templates/email_lostPassword.tpl | 2 +- .../templates/email_sendNewPassword.tpl | 2 +- .../database/update_com.woltlab.wcf_6.3_step1.php | 6 ++++++ .../install/files/lib/data/user/User.class.php | 2 +- .../files/lib/form/LostPasswordForm.class.php | 8 ++++---- .../files/lib/form/NewPasswordForm.class.php | 6 +++--- .../system/worker/SendNewPasswordWorker.class.php | 14 +++++++++++--- wcfsetup/install/lang/de.xml | 4 ++-- wcfsetup/install/lang/en.xml | 4 ++-- wcfsetup/setup/db/install_com.woltlab.wcf.php | 2 +- 10 files changed, 32 insertions(+), 18 deletions(-) diff --git a/com.woltlab.wcf/templates/email_lostPassword.tpl b/com.woltlab.wcf/templates/email_lostPassword.tpl index e8aba2978ef..d21bebe78e4 100644 --- a/com.woltlab.wcf/templates/email_lostPassword.tpl +++ b/com.woltlab.wcf/templates/email_lostPassword.tpl @@ -7,7 +7,7 @@ {lang}wcf.user.lostPassword.mail.html.intro{/lang} {capture assign=button} - + {lang}wcf.user.lostPassword.mail.html.reset{/lang} {/capture} diff --git a/com.woltlab.wcf/templates/email_sendNewPassword.tpl b/com.woltlab.wcf/templates/email_sendNewPassword.tpl index 1a96db9c685..6f57c7be218 100644 --- a/com.woltlab.wcf/templates/email_sendNewPassword.tpl +++ b/com.woltlab.wcf/templates/email_sendNewPassword.tpl @@ -7,7 +7,7 @@ {lang}wcf.acp.user.sendNewPassword.mail.html.intro{/lang} {capture assign=button} - + {lang}wcf.acp.user.sendNewPassword.mail.html.reset{/lang} {/capture} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index dc17365b4d7..652ee94424d 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -8,6 +8,7 @@ * @license GNU Lesser General Public License */ +use wcf\system\database\table\column\CharDatabaseTableColumn; use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn; use wcf\system\database\table\column\JsonDatabaseTableColumn; use wcf\system\database\table\column\MediumintDatabaseTableColumn; @@ -62,4 +63,9 @@ NotNullVarchar255DatabaseTableColumn::create('emoji') ->defaultValue(''), ]), + PartialDatabaseTable::create('wcf1_user') + ->columns([ + CharDatabaseTableColumn::create('lostPasswordKey') + ->length(64), + ]), ]; diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php index 5c921ceb462..c54510fd44f 100644 --- a/wcfsetup/install/files/lib/data/user/User.class.php +++ b/wcfsetup/install/files/lib/data/user/User.class.php @@ -40,7 +40,7 @@ * @property-read int $activationCode flag which determines, whether the user is activated (for legacy reasons an random integer, if the user is *not* activated) * @property-read ?string $emailConfirmed code sent to the user's email address used for account activation or null if the email is confirmed * @property-read int $lastLostPasswordRequestTime timestamp at which the user has reported that they lost their password or 0 if password has not been reported as lost - * @property-read ?string $lostPasswordKey code used for authenticating setting new password after password loss or empty if password has not been reported as lost + * @property-read ?string $lostPasswordKey SHA-256 hash of the code used for authenticating setting new password after password loss or empty if password has not been reported as lost * @property-read int $lastUsernameChange timestamp at which the user changed their name the last time or 0 if username has not been changed * @property-read string $newEmail new email address of the user that has to be manually confirmed or empty if no new email address has been set * @property-read string $oldUsername previous name of the user or empty if they have had no previous name diff --git a/wcfsetup/install/files/lib/form/LostPasswordForm.class.php b/wcfsetup/install/files/lib/form/LostPasswordForm.class.php index f1532c14798..440d23671eb 100644 --- a/wcfsetup/install/files/lib/form/LostPasswordForm.class.php +++ b/wcfsetup/install/files/lib/form/LostPasswordForm.class.php @@ -158,10 +158,10 @@ public function save() // generate a new lost password key $lostPasswordKey = Hex::encode(\random_bytes(20)); - // save key and request time in database + // save hashed key and request time in database $this->objectAction = new UserAction([$this->user], 'update', [ 'data' => \array_merge($this->additionalFields, [ - 'lostPasswordKey' => $lostPasswordKey, + 'lostPasswordKey' => \hash('sha256', $lostPasswordKey), 'lastLostPasswordRequestTime' => \TIME_NOW, ]), ]); @@ -174,8 +174,8 @@ public function save() $email->addRecipient(new UserMailbox($this->user)); $email->setSubject($this->user->getLanguage()->getDynamicVariable('wcf.user.lostPassword.mail.subject')); $email->setBody(new MimePartFacade([ - new RecipientAwareTextMimePart('text/html', 'email_lostPassword'), - new RecipientAwareTextMimePart('text/plain', 'email_lostPassword'), + new RecipientAwareTextMimePart('text/html', 'email_lostPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]), + new RecipientAwareTextMimePart('text/plain', 'email_lostPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]), ])); $email->send(); diff --git a/wcfsetup/install/files/lib/form/NewPasswordForm.class.php b/wcfsetup/install/files/lib/form/NewPasswordForm.class.php index 69c5b4866ae..30fdd546d64 100644 --- a/wcfsetup/install/files/lib/form/NewPasswordForm.class.php +++ b/wcfsetup/install/files/lib/form/NewPasswordForm.class.php @@ -56,7 +56,7 @@ public function readParameters() if (!$this->user->lostPasswordKey) { $this->throwInvalidLinkException(); } - if (!\hash_equals($this->user->lostPasswordKey, $this->lostPasswordKey)) { + if (!\hash_equals($this->user->lostPasswordKey, \hash('sha256', $this->lostPasswordKey))) { $this->throwInvalidLinkException(); } // expire lost password requests after a day @@ -66,7 +66,7 @@ public function readParameters() WCF::getSession()->register('lostPasswordRequest', [ 'userID' => $this->user->userID, - 'key' => $this->user->lostPasswordKey, + 'key' => $this->lostPasswordKey, ]); } else { if (!\is_array(WCF::getSession()->getVar('lostPasswordRequest'))) { @@ -78,7 +78,7 @@ public function readParameters() if (!$this->user->userID) { throw new IllegalLinkException(); } - if (!\hash_equals($this->user->lostPasswordKey, WCF::getSession()->getVar('lostPasswordRequest')['key'])) { + if (!\hash_equals($this->user->lostPasswordKey, \hash('sha256', WCF::getSession()->getVar('lostPasswordRequest')['key']))) { $this->throwInvalidLinkException(); } } diff --git a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php index 0ce7518ec37..bde3504904e 100644 --- a/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/SendNewPasswordWorker.class.php @@ -32,6 +32,11 @@ class SendNewPasswordWorker extends AbstractWorker */ protected $limit = 20; + /** + * @var array + */ + private array $lostPasswordKeys = []; + #[\Override] public function countObjects() { @@ -104,11 +109,13 @@ protected function resetPassword(UserEditor $userEditor) $userAction = new UserAction([$userEditor], 'update', [ 'data' => [ 'password' => null, - 'lostPasswordKey' => $lostPasswordKey, + 'lostPasswordKey' => \hash('sha256', $lostPasswordKey), 'lastLostPasswordRequestTime' => $lastLostPasswordRequestTime, ], ]); $userAction->executeAction(); + + $this->lostPasswordKeys[$userEditor->userID] = $lostPasswordKey; } /** @@ -128,9 +135,10 @@ protected function sendLink(User $user) )); $email->addRecipient(new UserMailbox($user)); $email->setSubject($user->getLanguage()->getDynamicVariable('wcf.acp.user.sendNewPassword.mail.subject')); + $lostPasswordKey = $this->lostPasswordKeys[$user->userID] ?? ''; $email->setBody(new MimePartFacade([ - new RecipientAwareTextMimePart('text/html', 'email_sendNewPassword'), - new RecipientAwareTextMimePart('text/plain', 'email_sendNewPassword'), + new RecipientAwareTextMimePart('text/html', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]), + new RecipientAwareTextMimePart('text/plain', 'email_sendNewPassword', 'wcf', ['lostPasswordKey' => $lostPasswordKey]), ])); $jobs = $email->getJobs(); foreach ($jobs as $job) { diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index d016a8a20b2..5d309aed303 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -3215,7 +3215,7 @@ erforderlich, dass {if LANGUAGE_USE_INFORMAL_VARIANT}du{else}Sie{/if} ein neues Benutzerkonto {@$mailbox->getUser()->username} auf der Seite {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] weiterhin verwenden {if LANGUAGE_USE_INFORMAL_VARIANT}kannst{else}können{/if}: - {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$mailbox->getUser()->lostPasswordKey}{/link} {* this line ends with a space *} + {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$lostPasswordKey}{/link} {* this line ends with a space *} {if LANGUAGE_USE_INFORMAL_VARIANT}Solltest du{else}Sollten Sie{/if} diese Nachricht erst nach dem {@$mailbox->getUser()->lastLostPasswordRequestTime+86400|plainTime} lesen, ist es aus Sicherheitsgründen erforderlich, dass {if LANGUAGE_USE_INFORMAL_VARIANT}du{else}Sie{/if} die Kennwort vergessen-Funktion [URL:{link controller='LostPassword' isEmail=true}{/link}] {if LANGUAGE_USE_INFORMAL_VARIANT}nutzt{else}nutzen{/if}.]]> @@ -4607,7 +4607,7 @@ Erlaubte Dateiendungen: gif, jpg, jpeg, png, webp]]> {@$mailbox->getUser()->username} auf der Seite {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] vergessen zu haben. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst dein{else}Sie können Ihr{/if} Kennwort nach einem Klick auf den folgenden Link ändern: - {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$mailbox->getUser()->lostPasswordKey}{/link} {* this line ends with a space *} + {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$lostPasswordKey}{/link} {* this line ends with a space *} Wenn {if LANGUAGE_USE_INFORMAL_VARIANT}du dein{else}Sie Ihr{/if} Kennwort nicht ändern {if LANGUAGE_USE_INFORMAL_VARIANT}möchtest{else}möchten{/if}, dann wird diese Anfrage am {@$mailbox->getUser()->lastLostPasswordRequestTime+86400|plainTime} automatisch ablaufen.]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index d5766cefddd..055e65c046e 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -3142,7 +3142,7 @@ If you have already bought the licenses for the listed apps, th An administrator has reset your password. You are now required to set a new password to be able to use your user account {@$mailbox->getUser()->username} on the website {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] again: - {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$mailbox->getUser()->lostPasswordKey}{/link} {* this line ends with a space *} + {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$lostPasswordKey}{/link} {* this line ends with a space *} If you read this message after {@$mailbox->getUser()->lastLostPasswordRequestTime+86400|plainTime} you’ll have to use the lost password form [URL:{link controller='LostPassword' isEmail=true}{/link}] for security reasons.]]> @@ -4606,7 +4606,7 @@ You (or someone else) claimed to have lost the password for the user account {@$ the website {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}]. You can change your password after clicking the following link: - {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$mailbox->getUser()->lostPasswordKey}{/link} {* this line ends with a space *} + {link controller='NewPassword' object=$mailbox->getUser() isEmail=true}k={@$lostPasswordKey}{/link} {* this line ends with a space *} If you don’t want to change your password you can simply wait. The request will expire at {@$mailbox->getUser()->lastLostPasswordRequestTime+86400|plainTime}.]]> getUser()->username},]]> diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 6b40769b76c..c2888092816 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3786,7 +3786,7 @@ NotNullInt10DatabaseTableColumn::create('lastLostPasswordRequestTime') ->defaultValue(0), CharDatabaseTableColumn::create('lostPasswordKey') - ->length(40), + ->length(64), NotNullInt10DatabaseTableColumn::create('lastUsernameChange') ->defaultValue(0), NotNullVarchar255DatabaseTableColumn::create('newEmail')