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) }} + +