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.dead_action')) ?> + escape($this->t('core.auth.back_to_login')) ?> +
+

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 {