Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions core/controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
}

/**
Expand Down
3 changes: 3 additions & 0 deletions core/languages/de/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions core/languages/en/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions core/languages/es/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions core/languages/fr/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions core/languages/hi/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => 'अपना पासवर्ड रीसेट करें',
Expand Down
3 changes: 3 additions & 0 deletions core/languages/pt/core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions core/views/scripts/auth/reset.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
?>
<div class="tiger-auth-brand">
<span class="tiger-auth-mark"><i class="fa-solid fa-link-slash"></i></span>
<h1 class="h4 mb-1"><?= $this->escape($this->t('core.auth.reset.dead_title')) ?></h1>
<p class="text-body-secondary small mb-0"><?= $this->escape($this->t('core.auth.reset.dead_body')) ?></p>
</div>

<div class="d-grid gap-2 mt-4">
<a href="/auth/forgot" class="btn btn-primary btn-lg"><?= $this->escape($this->t('core.auth.reset.dead_action')) ?></a>
<a href="/auth/login" class="btn btn-outline-secondary btn-lg"><?= $this->escape($this->t('core.auth.back_to_login')) ?></a>
</div>
<?php return; endif; ?>
<div class="tiger-auth-brand">
<span class="tiger-auth-mark"><i class="fa-solid fa-lock"></i></span>
<h1 class="h4 mb-1"><?= $this->escape($this->t('core.auth.reset.title')) ?></h1>
Expand Down
26 changes: 26 additions & 0 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions tests/Integration/Service/AuthenticationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading