Skip to content

✨ Add signed deep link for recovery-token regeneration (#97)#1255

Open
t2d wants to merge 3 commits intomainfrom
feature/recovery-token-deep-link
Open

✨ Add signed deep link for recovery-token regeneration (#97)#1255
t2d wants to merge 3 commits intomainfrom
feature/recovery-token-deep-link

Conversation

@t2d
Copy link
Copy Markdown
Contributor

@t2d t2d commented Apr 18, 2026

Goal

Close #97: the recovery notification mail now carries a single-click link that lets the user kill an in-progress recovery and rotate their recovery token with one password entry.

Before

%recovery_token_url% pointed at /account/recovery-token, which is firewall-gated. An anonymous user clicking the mail link had to:

  1. log in (email + password), plus 2FA if configured,
  2. re-enter the password on the regeneration form,
  3. press the button to rotate the token.

Two password prompts for a panic action.

After

The mail now contains a signed, time-limited URL at /recovery/token/regenerate?user={id}&_expiration=…&_hash=…. The landing page asks for the password once; on submit, the recovery token is rotated and the user is redirected to the existing public recovery_recovery_token_ack confirmation page. 2FA-enabled accounts additionally get a TOTP/backup-code field on the same form, so the security level of those accounts is unchanged.

Signing

Uses Symfony's built-in Symfony\Component\HttpFoundation\UriSigner (HMAC over APP_SECRET). Expiry is 30 days, matching RecoveryHandler::PROCESS_EXPIRE so the link stays useful for the same window as the recovery process itself. Tampered or expired URLs return 403.

Password / TOTP verification

  • Password is verified via UserAuthenticationHandler::authenticate(). Wrong password → flash "password incorrect", no state change.
  • If User::isTotpAuthenticationEnabled(), the form also validates a TOTP code or consumes a one-shot backup code via isBackupCode() + invalidateBackupCode().

Edge cases

  • Link clicked within the 48h waiting period: token is rotated, recoveryStartTime is cleared, the attacker's in-flight recovery is dead.
  • Link clicked after the attacker already completed /recovery/reset_password: the old password no longer works → generic "password incorrect" flash, no state change. We deliberately do not distinguish this from a mis-typed password because doing so would leak "this account's password has been rotated" to whoever holds the mail.
  • Link older than 30 days: 403.

Files

  • New: src/Controller/RecoveryTokenRegenerateController.php, src/Form/Model/RecoveryTokenRegenerate.php, src/Form/RecoveryTokenRegenerateType.php, templates/Recovery/token_regenerate.html.twig.
  • Modified: src/Mail/RecoveryProcessMailer.php — inject UriSigner, build the signed path; default_translations/{en,de}/messages.*.yml — new flash/headline/lead/2FA-label keys; features/recovery.feature + tests/Behat/FeatureContext.php — 8 new scenarios (happy path, wrong password, tampered sig, existing mailcrypt box, wrong password against mailcrypt box, TOTP wrong code, backup code, soft-deleted user) and helper steps; tests/Mail/RecoveryProcessMailerTest.php — updated for the new ctor, asserts the signed URL verifies; tests/Form/RecoveryTokenRegenerateTypeTest.php — form type coverage.

Test plan

  • bin/phpunit — 988 tests, 3718 assertions, green.
  • bin/behat --tags=@recovery — 16 scenarios (8 existing + 8 new), green.
  • Full bin/behat suite — only unrelated chromedriver/Panther failures on this machine; no other regressions.
  • bin/psalm — no errors.
  • bin/php-cs-fixer check — no style issues.
  • bin/rector --dry-run — clean.
  • Manual QA via Chrome DevTools MCP: logged in as user@example.org, generated a token, logged out, triggered recovery, pulled the signed link from Mailcatcher, clicked it anonymously, entered the password once → landed on /recovery/recovery_token/ack with a new token. Old token rejected, new token accepted on /recovery.
  • Manual QA: tampered _hash → 403 "Invalid or expired recovery link.".
  • Manual QA: wrong password on a valid signed URL → "The password you entered is incorrect. Please try again." flash, no token change.
  • Manual QA: same flow with 2FA enabled end-to-end (covered by Behat; not yet exercised in the browser).

🤖 Generated with Claude Code

t2d and others added 3 commits April 18, 2026 20:54
The recovery notification mail now contains a signed, time-limited link
that lets the user invalidate the in-progress recovery and rotate their
recovery token with a single password entry. If the user has 2FA
enabled, the same form also requires a TOTP or backup code so the
account's security level is preserved.

Previously, clicking %recovery_token_url% redirected anonymous users to
/login, then to the regeneration form, where the password had to be
entered a second time. The new route /recovery/token/regenerate is
public, identifies the user via a UriSigner-signed URL
(APP_SECRET HMAC, 30-day expiry matching PROCESS_EXPIRE), and submits
straight to recovery_recovery_token_ack on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recovery notification mail now points at /recovery/token/regenerate
(signed, public) instead of /account/recovery-token (firewall-gated).
Assert on the new path prefix; the query string carries the signature
and expiry, so match with a trailing "?" rather than the full URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds five Behat scenarios exercising the branches that weren't hit by
the initial happy-path + wrong-password + tampered-signature set:

- existing mailCryptSecretBox successfully re-encrypted
- existing mailCryptSecretBox rejects wrong password (decrypt exception)
- TOTP-enabled account rejects a wrong TOTP code
- TOTP-enabled account accepts a backup code (one-shot)
- soft-deleted user resolves to 404

Also adds a TypeTestCase for RecoveryTokenRegenerateType covering the
conditional TOTP field and both submit shapes. Three new Behat steps
support the scenarios: seeding a mailcrypt box, soft-deleting a user,
and filling a field with the cached backup code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 18, 2026

@t2d t2d marked this pull request as ready for review April 18, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[recovery] Implement deep link to reset the recovery process

1 participant