✨ Add signed deep link for recovery-token regeneration (#97)#1255
Open
✨ Add signed deep link for recovery-token regeneration (#97)#1255
Conversation
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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: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 publicrecovery_recovery_token_ackconfirmation 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 overAPP_SECRET). Expiry is 30 days, matchingRecoveryHandler::PROCESS_EXPIREso the link stays useful for the same window as the recovery process itself. Tampered or expired URLs return 403.Password / TOTP verification
UserAuthenticationHandler::authenticate(). Wrong password → flash "password incorrect", no state change.User::isTotpAuthenticationEnabled(), the form also validates a TOTP code or consumes a one-shot backup code viaisBackupCode()+invalidateBackupCode().Edge cases
recoveryStartTimeis cleared, the attacker's in-flight recovery is dead./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.Files
src/Controller/RecoveryTokenRegenerateController.php,src/Form/Model/RecoveryTokenRegenerate.php,src/Form/RecoveryTokenRegenerateType.php,templates/Recovery/token_regenerate.html.twig.src/Mail/RecoveryProcessMailer.php— injectUriSigner, 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.bin/behatsuite — 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.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/ackwith a new token. Old token rejected, new token accepted on/recovery._hash→ 403 "Invalid or expired recovery link.".🤖 Generated with Claude Code