From a3a1c32bcb7837a5fc088a929d05ba3f579c2a8e Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 24 Oct 2026 12:52:17 -0400 Subject: [PATCH] fix(auth): tell the user a reset link is dead BEFORE they choose a password (TIGER-387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A password-reset link that had expired, been used, or was malformed rendered a perfectly ordinary "Set a new password" screen. The user chose a password, typed it twice, submitted — and only then learned the link was dead. The refusal was always correct and safe; it just arrived after the work instead of before it. The people who hit that are the ones already locked out. The token is now checked on GET, and a dead one gets a card that says so and offers a one-click "Request a new link" straight to /auth/forgot. Tiger_Service_Authentication::resetTokenIsLive() is a READ-ONLY peek: it never consumes the challenge and never costs an attempt, so it is safe at render time. It checks the challenge only, not the code — a code cannot be verified without spending an attempt, and the challenge id is an opaque v4 UUID the link holder already has, so answering leaks nothing the link did not. resetPassword() still re-checks everything at submit, because a token can expire in between. Verified on tiger-dev through the browser, all three branches: bogus/empty token -> dead-link card live token -> form renders, reset succeeds, signs in at ?pwreset=1 consumed token -> dead-link card (the realistic case: an old link in the inbox) Tests: two integration tests, including that a peek does not burn the token and that unknown / consumed / expired / wrong-type challenges all read as not-live. Mutation-checked — an always-live stub fails them. i18n: the three new keys are in all six shipped locales. A missing key would render the raw key on a non-English page, since Zend_Translate has no fallback locale configured here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013vdyTeoDrGE4hKxt53TsE8 --- core/controllers/AuthController.php | 19 ++++++-- core/languages/de/core.php | 3 ++ core/languages/en/core.php | 3 ++ core/languages/es/core.php | 3 ++ core/languages/fr/core.php | 3 ++ core/languages/hi/core.php | 3 ++ core/languages/pt/core.php | 3 ++ core/views/scripts/auth/reset.phtml | 16 +++++++ library/Tiger/Service/Authentication.php | 26 +++++++++++ .../Service/AuthenticationTest.php | 43 +++++++++++++++++++ 10 files changed, 118 insertions(+), 4 deletions(-) diff --git a/core/controllers/AuthController.php b/core/controllers/AuthController.php index 7dcc773f..66813a09 100644 --- a/core/controllers/AuthController.php +++ b/core/controllers/AuthController.php @@ -194,7 +194,8 @@ public function forgotAction() } /** - * GET /auth/reset/cid/…/code/… -> set-a-new-password form (from the emailed link). + * GET /auth/reset/cid/…/code/… -> set-a-new-password form (from the emailed link), + * or the dead-link card when the token no longer resolves. * POST /auth/reset -> redeem the token + set the password (JSON). * * @return void @@ -229,9 +230,19 @@ public function resetAction() return; } $this->_helper->layout()->setLayout('auth'); - $this->view->title = 'Set a new password — Tiger'; - $this->view->cid = (string) $request->getParam('cid', ''); - $this->view->code = (string) $request->getParam('code', ''); + $this->view->cid = (string) $request->getParam('cid', ''); + $this->view->code = (string) $request->getParam('code', ''); + + // Check the token BEFORE rendering the form. A dead link used to render a perfectly + // ordinary set-a-new-password screen, so the user chose a password, typed it twice and + // only then learned the link had expired — and the people who hit that are the ones + // already locked out. Peek is read-only: it never consumes the challenge or costs an + // attempt, and resetPassword() still re-checks at submit (a token can expire in between). + $this->view->deadLink = !(new Tiger_Service_Authentication()) + ->resetTokenIsLive($this->view->cid); + $this->view->title = $this->view->deadLink + ? 'Link expired — Tiger' + : 'Set a new password — Tiger'; } /** diff --git a/core/languages/de/core.php b/core/languages/de/core.php index 7005e54b..33f640ff 100644 --- a/core/languages/de/core.php +++ b/core/languages/de/core.php @@ -92,6 +92,9 @@ 'core.auth.reset.new_password' => 'Neues Passwort', 'core.auth.reset.confirm_password' => 'Passwort bestätigen', 'core.auth.reset.submit' => 'Neues Passwort festlegen', + 'core.auth.reset.dead_title' => 'Dieser Link ist abgelaufen', + 'core.auth.reset.dead_body' => 'Links zum Zurücksetzen des Passworts sind einmalig nutzbar und zeitlich begrenzt. Fordern Sie einen neuen an – wir senden ihn Ihnen per E-Mail.', + 'core.auth.reset.dead_action' => 'Neuen Link anfordern', // --- Authentifizierung: Passwort vergessen --- 'core.auth.forgot.title' => 'Passwort zurücksetzen', diff --git a/core/languages/en/core.php b/core/languages/en/core.php index c381ab8b..2671a981 100644 --- a/core/languages/en/core.php +++ b/core/languages/en/core.php @@ -96,6 +96,9 @@ 'core.auth.reset.new_password' => 'New password', 'core.auth.reset.confirm_password' => 'Confirm password', 'core.auth.reset.submit' => 'Set new password', + 'core.auth.reset.dead_title' => 'This link has expired', + 'core.auth.reset.dead_body' => 'Password reset links are single-use and time-limited. Request a new one and we\'ll email it straight over.', + 'core.auth.reset.dead_action' => 'Request a new link', // --- Auth: forgot password --- 'core.auth.forgot.title' => 'Reset your password', diff --git a/core/languages/es/core.php b/core/languages/es/core.php index 7843aae0..45a0c527 100644 --- a/core/languages/es/core.php +++ b/core/languages/es/core.php @@ -92,6 +92,9 @@ 'core.auth.reset.new_password' => 'Nueva contraseña', 'core.auth.reset.confirm_password' => 'Confirmar contraseña', 'core.auth.reset.submit' => 'Establecer nueva contraseña', + 'core.auth.reset.dead_title' => 'Este enlace ha caducado', + 'core.auth.reset.dead_body' => 'Los enlaces para restablecer la contraseña son de un solo uso y caducan. Solicita uno nuevo y te lo enviaremos por correo.', + 'core.auth.reset.dead_action' => 'Solicitar un enlace nuevo', // --- Autenticación: contraseña olvidada --- 'core.auth.forgot.title' => 'Restablecer tu contraseña', diff --git a/core/languages/fr/core.php b/core/languages/fr/core.php index 968e8941..987061a2 100644 --- a/core/languages/fr/core.php +++ b/core/languages/fr/core.php @@ -92,6 +92,9 @@ 'core.auth.reset.new_password' => 'Nouveau mot de passe', 'core.auth.reset.confirm_password' => 'Confirmer le mot de passe', 'core.auth.reset.submit' => 'Définir le nouveau mot de passe', + 'core.auth.reset.dead_title' => 'Ce lien a expiré', + 'core.auth.reset.dead_body' => 'Les liens de réinitialisation sont à usage unique et à durée limitée. Demandez-en un nouveau et nous vous l\'enverrons par e-mail.', + 'core.auth.reset.dead_action' => 'Demander un nouveau lien', // --- Authentification : mot de passe oublié --- 'core.auth.forgot.title' => 'Réinitialiser votre mot de passe', diff --git a/core/languages/hi/core.php b/core/languages/hi/core.php index 88da6da7..2fce39f5 100644 --- a/core/languages/hi/core.php +++ b/core/languages/hi/core.php @@ -92,6 +92,9 @@ 'core.auth.reset.new_password' => 'नया पासवर्ड', 'core.auth.reset.confirm_password' => 'पासवर्ड की पुष्टि करें', 'core.auth.reset.submit' => 'नया पासवर्ड सेट करें', + 'core.auth.reset.dead_title' => 'यह लिंक समाप्त हो चुका है', + 'core.auth.reset.dead_body' => 'पासवर्ड रीसेट लिंक एक बार उपयोग के लिए होते हैं और इनकी एक समय-सीमा होती है। नया लिंक मांगें और हम उसे ईमेल कर देंगे।', + 'core.auth.reset.dead_action' => 'नया लिंक मांगें', // --- प्रमाणीकरण: पासवर्ड भूल गए --- 'core.auth.forgot.title' => 'अपना पासवर्ड रीसेट करें', diff --git a/core/languages/pt/core.php b/core/languages/pt/core.php index 5f4e6bd1..a115c9b1 100644 --- a/core/languages/pt/core.php +++ b/core/languages/pt/core.php @@ -92,6 +92,9 @@ 'core.auth.reset.new_password' => 'Nova senha', 'core.auth.reset.confirm_password' => 'Confirmar senha', 'core.auth.reset.submit' => 'Definir nova senha', + 'core.auth.reset.dead_title' => 'Este link expirou', + 'core.auth.reset.dead_body' => 'Os links de redefinição de senha são de uso único e têm prazo de validade. Solicite um novo e nós o enviaremos por e-mail.', + 'core.auth.reset.dead_action' => 'Solicitar um novo link', // --- Autenticação: senha esquecida --- 'core.auth.forgot.title' => 'Redefinir sua senha', diff --git a/core/views/scripts/auth/reset.phtml b/core/views/scripts/auth/reset.phtml index d0c91fd3..c10f0847 100644 --- a/core/views/scripts/auth/reset.phtml +++ b/core/views/scripts/auth/reset.phtml @@ -8,7 +8,23 @@ */ $cid = $this->escape($this->cid ?? ''); $code = $this->escape($this->code ?? ''); + +// A token that no longer resolves gets the dead-link card instead of the form — telling the +// user BEFORE they choose a password, not after they have typed it twice. The controller +// decides; the view only renders the verdict. +if (!empty($this->deadLink)): ?> +
+ +

escape($this->t('core.auth.reset.dead_title')) ?>

+

escape($this->t('core.auth.reset.dead_body')) ?>

+
+ + +

escape($this->t('core.auth.reset.title')) ?>

diff --git a/library/Tiger/Service/Authentication.php b/library/Tiger/Service/Authentication.php index e891c3f3..705cb755 100644 --- a/library/Tiger/Service/Authentication.php +++ b/library/Tiger/Service/Authentication.php @@ -269,6 +269,32 @@ public function requestPasswordReset($email, $baseUrl) * @param string $confirm the confirmation of the new password * @return array{ok:bool,error:?string} error = a ready-to-show message on failure */ + /** + * Is this password-reset token still redeemable? A READ-ONLY peek — it never consumes + * the challenge and never costs an attempt, so it is safe to call when merely RENDERING + * the set-a-new-password screen. + * + * It deliberately checks the challenge only, not the code: the code cannot be verified + * without spending an attempt, and the challenge id is an opaque v4 UUID that the holder + * of the link already has — so answering this leaks nothing the link itself did not. + * `resetPassword()` still re-checks everything at submit, because a token can expire + * between the render and the submit. + * + * @param string $challengeId the `cid` from the emailed link + * @return bool true when the link can still be redeemed + */ + public function resetTokenIsLive($challengeId) + { + $challengeId = (string) $challengeId; + if ($challengeId === '') { return false; } + + $row = (new Tiger_Model_AuthChallenge())->find($challengeId)->current(); + return (bool) ($row + && $row->type === 'password_reset' + && $row->consumed_at === null + && strtotime($row->expires_at) >= time()); + } + public function resetPassword($challengeId, $code, $newPassword, $confirm) { $newPassword = (string) $newPassword; diff --git a/tests/Integration/Service/AuthenticationTest.php b/tests/Integration/Service/AuthenticationTest.php index 448193bc..0cbb82ae 100644 --- a/tests/Integration/Service/AuthenticationTest.php +++ b/tests/Integration/Service/AuthenticationTest.php @@ -389,6 +389,49 @@ public function resetting_the_password_redeems_the_token_sets_the_new_password_a $this->assertFalse((new Tiger_Service_Authentication())->login($email, 'old password one'), 'the old password is dead'); } + #[Test] + public function reset_token_is_live_answers_without_consuming_the_challenge(): void + { + [$uid] = $this->makeUserWithPassword('peek at this one'); + $token = bin2hex(random_bytes(16)); + $cid = (new Tiger_Model_AuthChallenge())->issue($uid, 'password_reset', $token, 3600); + + // The peek is what the GET render asks before it decides to show the form at all. + $this->assertTrue($this->auth->resetTokenIsLive($cid), 'a fresh token reads as live'); + $this->assertTrue($this->auth->resetTokenIsLive($cid), 'asking twice does not change the answer'); + + // ...and it must not have cost the token anything: the redeem still works. + $out = $this->auth->resetPassword($cid, $token, 'after the peek 12', 'after the peek 12'); + $this->assertTrue($out['ok'], 'peeking did not consume the challenge or burn an attempt'); + } + + #[Test] + public function reset_token_is_live_is_false_for_unknown_consumed_expired_and_wrong_type_tokens(): void + { + [$uid] = $this->makeUserWithPassword('dead link cases 1'); + $model = new Tiger_Model_AuthChallenge(); + + $this->assertFalse($this->auth->resetTokenIsLive(''), 'an empty cid is not live'); + $this->assertFalse($this->auth->resetTokenIsLive('not-a-real-token-000'), 'an unknown cid is not live'); + + // Consumed — the realistic case: the user clicks the same emailed link a second time. + $token = bin2hex(random_bytes(16)); + $cid = $model->issue($uid, 'password_reset', $token, 3600); + $this->auth->resetPassword($cid, $token, 'consume me please 1', 'consume me please 1'); + $this->assertFalse($this->auth->resetTokenIsLive($cid), 'a redeemed token is not live'); + + // Expired. + $expiredToken = bin2hex(random_bytes(16)); + $expiredCid = $model->issue($uid, 'password_reset', $expiredToken, 1); + $model->update(['expires_at' => gmdate('Y-m-d H:i:s', time() - 60)], + $model->getAdapter()->quoteInto('challenge_id = ?', $expiredCid)); + $this->assertFalse($this->auth->resetTokenIsLive($expiredCid), 'an expired token is not live'); + + // A challenge of another TYPE must never open the reset screen. + $otherCid = $model->issue($uid, 'email_login', '135791', 3600); + $this->assertFalse($this->auth->resetTokenIsLive($otherCid), 'a non-reset challenge is not a reset token'); + } + #[Test] public function a_reset_with_a_mismatched_confirmation_or_wrong_code_fails_without_burning_the_token(): void {