diff --git a/default_translations/de/messages.de.yml b/default_translations/de/messages.de.yml index 1880f9239..bf0e1e8db 100644 --- a/default_translations/de/messages.de.yml +++ b/default_translations/de/messages.de.yml @@ -108,6 +108,7 @@ form: twofactor-login: Zwei-Faktor-Authentifizierung twofactor-login-placeholder: 6-stelliger Code twofactor-login-auth-code: Authentifizierungs-Code + twofactor-code: Authentifizierungs-Code (oder Backup-Code) twofactor-backup-code-confirm: Backup-Codes sicher abgelegt twofactor-login-desc: Öffne deine Zwei-Faktor (TOTP) App für den Code. twofactor-login-cancel: Login abbrechen @@ -278,6 +279,11 @@ recovery-token: created-subtitle: Bitte kopiere und speicher dein Wiederherstellungscode sicher. created-info: Mit dem Wiederherstellungscode kannst du jederzeit dein Passwort zurücksetzen, wenn du dieses vergessen hast. Notiere ihn dir und speichere ihn an einem sicheren Ort ab. displayed-once: Der Wiederherstellungscode wird aus Sicherheitsgründen nur einmal angezeigt. Wir können ihn nicht für dich wiederherstellen. +recovery-token-regenerate: + headline: Neuen Wiederherstellungscode erstellen + lead: > + Gib das Passwort für %email% ein, um den aktuellen Wiederherstellungscode + sofort zu entwerten und einen neuen zu erzeugen. flashes: dismiss: Schließen logout-successful: Du bist jetzt abgemeldet. @@ -299,6 +305,8 @@ flashes: openpgp-key-upload-error-multiple-keys: Die hochgeladene Datei enhält mehr als einen Schlüssel für deine E-Mail-Adresse. Bitte lade nur einen Schlüssel hoch. openpgp-key-unauthorized: Du bist nicht berechtigt, Schlüssel für diese Adresse zu verwalten. password-confirmation-failed: Das eingegebene Passwort ist falsch. Bitte versuche es erneut. + recovery-token-regenerated: Dein neuer Wiederherstellungscode wurde erzeugt. + twofactor-code-invalid: Der eingegebene Zwei-Faktor-Code ist ungültig. flash_batch_remove_vouchers_success: Nicht eingelöste Einladungscodes gelöscht. error: title: Oops, es ist ein Fehler passiert. diff --git a/default_translations/en/messages.en.yml b/default_translations/en/messages.en.yml index 922394b90..b796de43c 100644 --- a/default_translations/en/messages.en.yml +++ b/default_translations/en/messages.en.yml @@ -112,6 +112,7 @@ form: twofactor-login: Two-factor authentication twofactor-login-placeholder: 6-digit code twofactor-login-auth-code: Authentication code + twofactor-code: Authentication code (or backup code) twofactor-backup-code-confirm: Backup codes stored securely twofactor-login-desc: Open your two-factor authenticator (TOTP) app to view your authentication code. twofactor-login-cancel: Cancel login @@ -282,6 +283,11 @@ recovery-token: displayed-once: > For security reasons, the recovery token gets displayed only once. We can't recover this token for you. +recovery-token-regenerate: + headline: Create a new recovery token + lead: > + Enter the password for %email% to immediately invalidate the current recovery + token and generate a new one. flashes: dismiss: Dismiss logout-successful: You are now logged out. @@ -303,6 +309,8 @@ flashes: openpgp-key-upload-error-multiple-keys: More than one key for your mail address found in uploaded data. Please upload only one key. openpgp-key-unauthorized: You are not authorized to manage this identity. password-confirmation-failed: The password you entered is incorrect. Please try again. + recovery-token-regenerated: Your new recovery token has been generated. + twofactor-code-invalid: The two-factor authentication code you entered is invalid. flash_batch_remove_vouchers_success: Unredeemed invite codes deleted. error: title: Oops, an error occured. diff --git a/features/mail_links.feature b/features/mail_links.feature index 91a4766ea..c4a897e46 100644 --- a/features/mail_links.feature +++ b/features/mail_links.feature @@ -26,4 +26,4 @@ Feature: Mail links Then I should see text matching "Second step starts at" And the last sent mail body should contain "https://mail.example.org/recovery" - And the last sent mail body should contain "https://mail.example.org/account/recovery-token" + And the last sent mail body should contain "https://mail.example.org/recovery/token/regenerate?" diff --git a/features/recovery.feature b/features/recovery.feature index 473dc97da..1a9810f5c 100644 --- a/features/recovery.feature +++ b/features/recovery.feature @@ -108,3 +108,86 @@ Feature: Recovery Then I should be on "/recovery" And I should see text matching "This token has an invalid format." + + @recovery + Scenario: Regenerate recovery token via signed mail link in a single password entry + Given I have a signed recovery-token regenerate URL for "user@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + Then the response status code should be 200 + And I should see text matching "Create a new recovery token" + When I fill in "recovery_token_regenerate[password]" with "passwordtest" + And I press "recovery_token_regenerate[submit]" + + Then I should be on "/recovery/recovery_token/ack" + And I should see text matching "Your new recovery token has been generated." + And the response status code should be 200 + + @recovery + Scenario: Deep-link regeneration rejects a wrong password without generating a new token + Given I have a signed recovery-token regenerate URL for "user@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + And I fill in "recovery_token_regenerate[password]" with "wrong-password" + And I press "recovery_token_regenerate[submit]" + + Then I should see text matching "The password you entered is incorrect" + And the response status code should be 200 + + @recovery + Scenario: Deep-link regeneration rejects tampered signatures + When I am on "/recovery/token/regenerate?user=1&_expiration=9999999999&_hash=invalid" + Then the response status code should be 403 + + @recovery + Scenario: Deep-link regeneration re-encrypts an existing mailcrypt key + Given the User "user@example.org" has a mailcrypt secret box for password "passwordtest" + And I have a signed recovery-token regenerate URL for "user@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + And I fill in "recovery_token_regenerate[password]" with "passwordtest" + And I press "recovery_token_regenerate[submit]" + + Then I should be on "/recovery/recovery_token/ack" + And I should see text matching "Your new recovery token has been generated." + + @recovery + Scenario: Deep-link regeneration rejects wrong password when a mailcrypt box exists + Given the User "user@example.org" has a mailcrypt secret box for password "passwordtest" + And I have a signed recovery-token regenerate URL for "user@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + And I fill in "recovery_token_regenerate[password]" with "wrong-password" + And I press "recovery_token_regenerate[submit]" + + Then I should see text matching "The password you entered is incorrect" + + @recovery + Scenario: Deep-link regeneration requires TOTP when two-factor auth is enabled + Given the following User exists: + | email | password | roles | totpConfirmed | totpSecret | recoverySecretBox | recoveryStartTime | totp_backup_codes | + | twofa@example.org | passwordtest | ROLE_USER | 1 | JBSWY3DPEHPK3PXP | jsMdwp9tMK+6x1wuaWbtW67pJxSCYZTWOGhFL12LVMPAIWnR5Zjoe8pAdkUcg/J/S16xEqAvD6uWaBSw43aUk03TXdGKb1mW67dTSf/1UcG98meaIuyY+RrvhQn7KRKQ97PYb40T9BHP77GQkO0EajaNUBSodQpNDoZ3flZHe5wxfiZs6822HRe1hNtuURv/8sRSQG859ff0w4cdaqcd2hBbo0nQT1wDtjLN7t2rbtUeXemI+1tfMXiEK+wTu22Zkv/LiyZSBrhW8hdZBYri1O4nB4XwFsRILDj6ei7gZkebcoT0YwdZE1KNmKmjOxTjG78UJrCyp0uw+HuI2A3iA3wAbxCTJODkGuMVdJdG0fFF/k5PgAUt2rWrLmQEQs3jJQNKh5uy6bCoVnSmmfaRAWBj7klDgV98PJWr4D+K1ZrWngS/wCO4AuM7NiStGUR3IUZKhfLrAA5KBBva5LOrxyn+u8TVY6K9gaOvKLfl0DIYHKJtntiMRjNvoAHlaCpO9F2VZBjwIOsybVh6Dul+vclFMWNMtm10aHS9fRyk9t0j4rTELCV65ORKWHQLirlyhdUjDpQ/wy867h9aiNP2QfgRrQG3t5Dyh9Xg6b0b+RpqHQ5FJIxsL2ZNm73JoAXYnMbqep0idBXUZkdeOD++ezg7e+qsl6Zkvm6dqj+Cp8UHV0sNY5o0E3rMxZeh79Tu6TxvADNnRPdMnMWPssjppU3jHzdvGEkXViDGN3V2X140cy6RqH79Wg== | NOW | true | + And I have a signed recovery-token regenerate URL for "twofa@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + Then I should see text matching "Authentication code" + + When I fill in "recovery_token_regenerate[password]" with "passwordtest" + And I fill in "recovery_token_regenerate[totpCode]" with "000000" + And I press "recovery_token_regenerate[submit]" + Then I should see text matching "two-factor authentication code you entered is invalid" + + @recovery + Scenario: Deep-link regeneration accepts a TOTP backup code and consumes it + Given the following User exists: + | email | password | roles | totpConfirmed | totpSecret | recoverySecretBox | recoveryStartTime | totp_backup_codes | + | backups@example.org | passwordtest | ROLE_USER | 1 | JBSWY3DPEHPK3PXP | jsMdwp9tMK+6x1wuaWbtW67pJxSCYZTWOGhFL12LVMPAIWnR5Zjoe8pAdkUcg/J/S16xEqAvD6uWaBSw43aUk03TXdGKb1mW67dTSf/1UcG98meaIuyY+RrvhQn7KRKQ97PYb40T9BHP77GQkO0EajaNUBSodQpNDoZ3flZHe5wxfiZs6822HRe1hNtuURv/8sRSQG859ff0w4cdaqcd2hBbo0nQT1wDtjLN7t2rbtUeXemI+1tfMXiEK+wTu22Zkv/LiyZSBrhW8hdZBYri1O4nB4XwFsRILDj6ei7gZkebcoT0YwdZE1KNmKmjOxTjG78UJrCyp0uw+HuI2A3iA3wAbxCTJODkGuMVdJdG0fFF/k5PgAUt2rWrLmQEQs3jJQNKh5uy6bCoVnSmmfaRAWBj7klDgV98PJWr4D+K1ZrWngS/wCO4AuM7NiStGUR3IUZKhfLrAA5KBBva5LOrxyn+u8TVY6K9gaOvKLfl0DIYHKJtntiMRjNvoAHlaCpO9F2VZBjwIOsybVh6Dul+vclFMWNMtm10aHS9fRyk9t0j4rTELCV65ORKWHQLirlyhdUjDpQ/wy867h9aiNP2QfgRrQG3t5Dyh9Xg6b0b+RpqHQ5FJIxsL2ZNm73JoAXYnMbqep0idBXUZkdeOD++ezg7e+qsl6Zkvm6dqj+Cp8UHV0sNY5o0E3rMxZeh79Tu6TxvADNnRPdMnMWPssjppU3jHzdvGEkXViDGN3V2X140cy6RqH79Wg== | NOW | true | + And I have a signed recovery-token regenerate URL for "backups@example.org" as placeholder "regenerate_url" + When I am on "regenerate_url" + And I fill in "recovery_token_regenerate[password]" with "passwordtest" + And I fill field "recovery_token_regenerate[totpCode]" with the first TOTP backup code + And I press "recovery_token_regenerate[submit]" + + Then I should be on "/recovery/recovery_token/ack" + + @recovery + Scenario: Deep-link regeneration returns 404 for a soft-deleted user + Given I have a signed recovery-token regenerate URL for "user@example.org" as placeholder "regenerate_url" + And the User "user@example.org" is soft-deleted + When I am on "regenerate_url" + Then the response status code should be 404 diff --git a/src/Controller/RecoveryTokenRegenerateController.php b/src/Controller/RecoveryTokenRegenerateController.php new file mode 100644 index 000000000..017bcfff5 --- /dev/null +++ b/src/Controller/RecoveryTokenRegenerateController.php @@ -0,0 +1,184 @@ +verifyRequestAndLoadUser($request); + + $form = $this->createForm( + RecoveryTokenRegenerateType::class, + new RecoveryTokenRegenerate(), + [ + 'action' => $request->getRequestUri(), + 'method' => 'post', + 'requires_totp' => $user->isTotpAuthenticationEnabled(), + ], + ); + + return $this->render('Recovery/token_regenerate.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + #[Route( + path: '/recovery/token/regenerate', + name: 'recovery_token_regenerate_submit', + methods: ['POST'], + )] + public function submit(Request $request): Response + { + $user = $this->verifyRequestAndLoadUser($request); + $requiresTotp = $user->isTotpAuthenticationEnabled(); + + $data = new RecoveryTokenRegenerate(); + $form = $this->createForm( + RecoveryTokenRegenerateType::class, + $data, + ['requires_totp' => $requiresTotp], + ); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (null === $this->authenticationHandler->authenticate($user, $data->getPassword())) { + $this->addFlash('error', 'flashes.password-confirmation-failed'); + + return $this->render('Recovery/token_regenerate.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + if ($requiresTotp && !$this->verifyTotp($user, (string) $data->getTotpCode())) { + $this->addFlash('error', 'flashes.twofactor-code-invalid'); + + return $this->render('Recovery/token_regenerate.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + try { + $this->regenerateRecoveryToken($user, $data->getPassword()); + } catch (Exception) { + $this->addFlash('error', 'flashes.password-confirmation-failed'); + + return $this->render('Recovery/token_regenerate.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + if (null === $recoveryToken = $user->getPlainRecoveryToken()) { + throw new LogicException('plainRecoveryToken should not be null'); + } + + $user->eraseCredentials(); + $this->addFlash('success', 'flashes.recovery-token-regenerated'); + + return $this->redirectToRoute('recovery_recovery_token_ack', [ + 'recoveryToken' => $recoveryToken, + ]); + } + + return $this->render('Recovery/token_regenerate.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + private function verifyRequestAndLoadUser(Request $request): User + { + if (!$this->uriSigner->check($request->getRequestUri())) { + throw new AccessDeniedHttpException('Invalid or expired recovery link.'); + } + + $userId = $request->query->getInt('user'); + if ($userId <= 0) { + throw new AccessDeniedHttpException('Invalid recovery link.'); + } + + $user = $this->userRepository->find($userId); + if (!$user instanceof User || $user->isDeleted()) { + throw new NotFoundHttpException('User not found.'); + } + + return $user; + } + + private function verifyTotp(User $user, string $code): bool + { + if ('' === $code) { + return false; + } + + if ($this->totpAuthenticator->checkCode($user, $code)) { + return true; + } + + if ($user->isBackupCode($code)) { + $user->invalidateBackupCode($code); + $this->manager->flush(); + + return true; + } + + return false; + } + + /** + * Rotate the recovery token, re-encrypting the MailCrypt private key when present. + * + * @throws Exception on password mismatch against an existing MailCrypt secret box + */ + private function regenerateRecoveryToken(User $user, string $password): void + { + if ($user->hasMailCryptSecretBox()) { + $user->setPlainMailCryptPrivateKey($this->mailCryptKeyHandler->decrypt($user, $password)); + } else { + $this->mailCryptKeyHandler->create($user, $password); + } + + $this->recoveryTokenHandler->create($user); + } +} diff --git a/src/Form/Model/RecoveryTokenRegenerate.php b/src/Form/Model/RecoveryTokenRegenerate.php new file mode 100644 index 000000000..1960dc999 --- /dev/null +++ b/src/Form/Model/RecoveryTokenRegenerate.php @@ -0,0 +1,32 @@ +password; + } + + public function setPassword(string $password): void + { + $this->password = $password; + } + + public function getTotpCode(): ?string + { + return $this->totpCode; + } + + public function setTotpCode(?string $totpCode): void + { + $this->totpCode = $totpCode; + } +} diff --git a/src/Form/RecoveryTokenRegenerateType.php b/src/Form/RecoveryTokenRegenerateType.php new file mode 100644 index 000000000..10e1187f7 --- /dev/null +++ b/src/Form/RecoveryTokenRegenerateType.php @@ -0,0 +1,61 @@ + + */ +final class RecoveryTokenRegenerateType extends AbstractType +{ + public const string NAME = 'recovery_token_regenerate'; + + #[Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('password', PasswordType::class, [ + 'label' => 'form.password', + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'form.generate-recovery-token', + ]); + + if ($options['requires_totp']) { + $builder->add('totpCode', TextType::class, [ + 'label' => 'form.twofactor-code', + 'required' => true, + 'attr' => [ + 'autocomplete' => 'one-time-code', + 'inputmode' => 'numeric', + ], + ]); + } + } + + #[Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RecoveryTokenRegenerate::class, + 'requires_totp' => false, + ]); + $resolver->setAllowedTypes('requires_totp', 'bool'); + } + + #[Override] + public function getBlockPrefix(): string + { + return self::NAME; + } +} diff --git a/src/Mail/RecoveryProcessMailer.php b/src/Mail/RecoveryProcessMailer.php index d82f94668..649075283 100644 --- a/src/Mail/RecoveryProcessMailer.php +++ b/src/Mail/RecoveryProcessMailer.php @@ -9,16 +9,25 @@ use App\Service\SettingsService; use DateInterval; use IntlDateFormatter; +use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; final readonly class RecoveryProcessMailer { + /** + * Expiry window for the one-click recovery-token regeneration link. + * Matches RecoveryHandler::PROCESS_EXPIRE so the link stays usable + * as long as the recovery process itself is still valid. + */ + private const string REGENERATE_LINK_TTL = 'P30D'; + public function __construct( private MailHandler $handler, private TranslatorInterface $translator, private SettingsService $settingsService, private UrlGeneratorInterface $urlGenerator, + private UriSigner $uriSigner, ) { } @@ -28,12 +37,12 @@ public function send(User $user, string $locale): void $formatter = IntlDateFormatter::create($locale, IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); $time = $formatter->format($user->getRecoveryStartTime()->add(new DateInterval('P2D'))); - $body = $this->buildBody($locale, $email, $time); + $body = $this->buildBody($user, $locale, $email, $time); $subject = $this->buildSubject($locale, $email); $this->handler->send($email, $body, $subject); } - private function buildBody(string $locale, string $email, string $time): string + private function buildBody(User $user, string $locale, string $email, string $time): string { // Host from the admin-editable `app_url` setting, path from the router. This keeps // a single source of truth for the host and works in background workers where no @@ -47,13 +56,24 @@ private function buildBody(string $locale, string $email, string $time): string '%email%' => $email, '%time%' => $time, '%recovery_url%' => $appUrl.$this->urlGenerator->generate('recovery', [], UrlGeneratorInterface::ABSOLUTE_PATH), - '%recovery_token_url%' => $appUrl.$this->urlGenerator->generate('account_recovery_token', [], UrlGeneratorInterface::ABSOLUTE_PATH), + '%recovery_token_url%' => $appUrl.$this->buildSignedRegeneratePath($user), ], null, $locale ); } + private function buildSignedRegeneratePath(User $user): string + { + $path = $this->urlGenerator->generate( + 'recovery_token_regenerate', + ['user' => $user->getId()], + UrlGeneratorInterface::ABSOLUTE_PATH, + ); + + return $this->uriSigner->sign($path, new DateInterval(self::REGENERATE_LINK_TTL)); + } + private function buildSubject(string $locale, string $email): string { return $this->translator->trans( diff --git a/templates/Recovery/token_regenerate.html.twig b/templates/Recovery/token_regenerate.html.twig new file mode 100644 index 000000000..90e573a20 --- /dev/null +++ b/templates/Recovery/token_regenerate.html.twig @@ -0,0 +1,33 @@ +{% extends 'Recovery/recovery.html.twig' %} + +{% block step_title %}{{ "recovery-token-regenerate.headline"|trans }}{% endblock %} + +{% block step_description %} +

+ {{ "recovery-token-regenerate.lead"|trans({'%email%': user.email}) }} +

+{% endblock %} + +{% block recovery_content %} + {{ form_start(form, {'attr': {'class': 'space-y-6'}}) }} + {{ form_errors(form) }} + +
+ {{ form_label(form.password, null, {'label_attr': {'class': 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'}}) }} + {{ form_errors(form.password) }} + {{ form_widget(form.password, {'attr': {'placeholder': 'Password'|trans, 'autocomplete': 'current-password'}}) }} +
+ + {% if form.totpCode is defined %} +
+ {{ form_label(form.totpCode, null, {'label_attr': {'class': 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'}}) }} + {{ form_errors(form.totpCode) }} + {{ form_widget(form.totpCode) }} +
+ {% endif %} + +
+ {{ form_widget(form.submit, {'attr': {'class': 'w-full'}}) }} +
+ {{ form_end(form) }} +{% endblock %} diff --git a/tests/Behat/FeatureContext.php b/tests/Behat/FeatureContext.php index 1cffefddb..3e2813d16 100644 --- a/tests/Behat/FeatureContext.php +++ b/tests/Behat/FeatureContext.php @@ -28,6 +28,7 @@ use Behat\Mink\Element\NodeElement; use Behat\Mink\Exception\UnsupportedDriverActionException; use Behat\MinkExtension\Context\MinkContext; +use DateInterval; use DateTimeImmutable; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\ORM\EntityManagerInterface; @@ -628,6 +629,70 @@ public function iSetPlaceholderFromUrl(string $name, string $key): void } } + /** + * @Given /^I have a signed recovery-token regenerate URL for "([^"]*)" as placeholder "([^"]*)"$/ + */ + public function iHaveASignedRecoveryTokenRegenerateUrl(string $email, string $placeholder): void + { + $user = $this->getUserRepository()->findByEmail($email); + if (null === $user) { + throw new RuntimeException(sprintf('User "%s" does not exist', $email)); + } + + $container = $this->getContainer(); + $urlGenerator = $container->get('router'); + $uriSigner = $container->get('uri_signer'); + + $path = $urlGenerator->generate( + 'recovery_token_regenerate', + ['user' => $user->getId()], + \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_PATH, + ); + + $this->setPlaceholder($placeholder, $uriSigner->sign($path, new DateInterval('P30D'))); + } + + /** + * @Given /^the User "([^"]*)" has a mailcrypt secret box for password "([^"]*)"$/ + */ + public function theUserHasAMailcryptSecretBox(string $email, string $password): void + { + $user = $this->getUserRepository()->findByEmail($email); + if (null === $user) { + throw new RuntimeException(sprintf('User "%s" does not exist', $email)); + } + + $this->getContainer() + ->get(\App\Handler\MailCryptKeyHandler::class) + ->create($user, $password); + } + + /** + * @Given /^the User "([^"]*)" is soft-deleted$/ + */ + public function theUserIsSoftDeleted(string $email): void + { + $user = $this->getUserRepository()->findByEmail($email); + if (null === $user) { + throw new RuntimeException(sprintf('User "%s" does not exist', $email)); + } + + $user->setDeleted(true); + $this->manager->flush(); + } + + /** + * @When /^I fill field "([^"]*)" with the first TOTP backup code$/ + */ + public function iFillFieldWithFirstTotpBackupCode(string $fieldId): void + { + $backupCodes = $this->getPlaceholder('totp_backup_codes'); + if (!is_array($backupCodes) || [] === $backupCodes) { + throw new RuntimeException('No TOTP backup codes cached'); + } + $this->fillField($fieldId, $backupCodes[0]); + } + /** * @When /^I generate a TOTP code from "([^"]*)" and fill to field "([^"]*)"/ */ diff --git a/tests/Form/RecoveryTokenRegenerateTypeTest.php b/tests/Form/RecoveryTokenRegenerateTypeTest.php new file mode 100644 index 000000000..5098fdd50 --- /dev/null +++ b/tests/Form/RecoveryTokenRegenerateTypeTest.php @@ -0,0 +1,73 @@ +dispatcher = $this->createStub(EventDispatcherInterface::class); + parent::setUp(); + } + + public function testOmitsTotpFieldByDefault(): void + { + $form = $this->factory->create(RecoveryTokenRegenerateType::class); + + $children = $form->createView()->children; + + self::assertArrayHasKey('password', $children); + self::assertArrayHasKey('submit', $children); + self::assertArrayNotHasKey('totpCode', $children); + } + + public function testIncludesTotpFieldWhenRequested(): void + { + $form = $this->factory->create(RecoveryTokenRegenerateType::class, null, [ + 'requires_totp' => true, + ]); + + $children = $form->createView()->children; + + self::assertArrayHasKey('totpCode', $children); + self::assertSame('form.twofactor-code', $children['totpCode']->vars['label']); + } + + public function testSubmitPasswordOnlyData(): void + { + $form = $this->factory->create(RecoveryTokenRegenerateType::class); + + $form->submit(['password' => 's3cret']); + + self::assertTrue($form->isSynchronized()); + $data = $form->getData(); + self::assertInstanceOf(RecoveryTokenRegenerate::class, $data); + self::assertSame('s3cret', $data->getPassword()); + self::assertNull($data->getTotpCode()); + } + + public function testSubmitWithTotpBindsBothFields(): void + { + $form = $this->factory->create(RecoveryTokenRegenerateType::class, null, [ + 'requires_totp' => true, + ]); + + $form->submit([ + 'password' => 's3cret', + 'totpCode' => '123456', + ]); + + self::assertTrue($form->isSynchronized()); + $data = $form->getData(); + self::assertInstanceOf(RecoveryTokenRegenerate::class, $data); + self::assertSame('s3cret', $data->getPassword()); + self::assertSame('123456', $data->getTotpCode()); + } +} diff --git a/tests/Mail/RecoveryProcessMailerTest.php b/tests/Mail/RecoveryProcessMailerTest.php index ef7fc290f..033f9de72 100644 --- a/tests/Mail/RecoveryProcessMailerTest.php +++ b/tests/Mail/RecoveryProcessMailerTest.php @@ -10,6 +10,7 @@ use App\Service\SettingsService; use DateTimeImmutable; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -38,7 +39,13 @@ public function testSendCallsTranslatorAndMailHandler(): void ->method('send') ->with('user@example.org', 'Recovery body', 'Recovery subject'); - $mailer = new RecoveryProcessMailer($handler, $translator, $settingsService, $this->createStub(UrlGeneratorInterface::class)); + $mailer = new RecoveryProcessMailer( + $handler, + $translator, + $settingsService, + $this->createStub(UrlGeneratorInterface::class), + new UriSigner('secret'), + ); $mailer->send($user, 'en'); } @@ -62,13 +69,20 @@ public function testSendPassesLocaleToTranslator(): void $handler = $this->createMock(MailHandler::class); $handler->expects(self::once())->method('send'); - $mailer = new RecoveryProcessMailer($handler, $translator, $settingsService, $this->createStub(UrlGeneratorInterface::class)); + $mailer = new RecoveryProcessMailer( + $handler, + $translator, + $settingsService, + $this->createStub(UrlGeneratorInterface::class), + new UriSigner('secret'), + ); $mailer->send($user, 'de'); } public function testSendPassesExpectedPlaceholdersToTranslator(): void { $user = new User('user@example.org'); + $user->setId(42); $user->setRecoveryStartTime(new DateTimeImmutable('2026-01-15 10:00:00')); $settingsService = $this->createStub(SettingsService::class); @@ -80,19 +94,27 @@ public function testSendPassesExpectedPlaceholdersToTranslator(): void $urlGenerator = $this->createStub(UrlGeneratorInterface::class); $urlGenerator->method('generate')->willReturnMap([ ['recovery', [], UrlGeneratorInterface::ABSOLUTE_PATH, '/recovery'], - ['account_recovery_token', [], UrlGeneratorInterface::ABSOLUTE_PATH, '/account/recovery-token'], + ['recovery_token_regenerate', ['user' => 42], UrlGeneratorInterface::ABSOLUTE_PATH, '/recovery/token/regenerate?user=42'], ]); + $uriSigner = new UriSigner('secret'); + $translator = $this->createMock(TranslatorInterface::class); $translator->expects(self::exactly(2)) ->method('trans') - ->willReturnCallback(static function (string $id, array $parameters) { + ->willReturnCallback(static function (string $id, array $parameters) use ($uriSigner) { if ('mail.recovery-body' === $id) { self::assertSame('Example Mail', $parameters['%project_name%']); self::assertSame('user@example.org', $parameters['%email%']); self::assertArrayHasKey('%time%', $parameters); self::assertSame('https://mail.example.com/recovery', $parameters['%recovery_url%']); - self::assertSame('https://mail.example.com/account/recovery-token', $parameters['%recovery_token_url%']); + + // The regenerate URL is signed and time-limited; verify it's valid + // and points at the expected route with the user id. + $regenerateUrl = $parameters['%recovery_token_url%']; + self::assertStringStartsWith('https://mail.example.com/recovery/token/regenerate?', $regenerateUrl); + self::assertStringContainsString('user=42', $regenerateUrl); + self::assertTrue($uriSigner->check(substr($regenerateUrl, strlen('https://mail.example.com')))); } if ('mail.recovery-subject' === $id) { self::assertSame('user@example.org', $parameters['%email%']); @@ -101,7 +123,13 @@ public function testSendPassesExpectedPlaceholdersToTranslator(): void return 'translated'; }); - $mailer = new RecoveryProcessMailer($this->createStub(MailHandler::class), $translator, $settingsService, $urlGenerator); + $mailer = new RecoveryProcessMailer( + $this->createStub(MailHandler::class), + $translator, + $settingsService, + $urlGenerator, + $uriSigner, + ); $mailer->send($user, 'en'); } }