Skip to content
Open
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
8 changes: 8 additions & 0 deletions default_translations/de/messages.de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions default_translations/en/messages.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion features/mail_links.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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?"
83 changes: 83 additions & 0 deletions features/recovery.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
184 changes: 184 additions & 0 deletions src/Controller/RecoveryTokenRegenerateController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use App\Form\Model\RecoveryTokenRegenerate;
use App\Form\RecoveryTokenRegenerateType;
use App\Handler\MailCryptKeyHandler;
use App\Handler\RecoveryTokenHandler;
use App\Handler\UserAuthenticationHandler;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use LogicException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;

final class RecoveryTokenRegenerateController extends AbstractController
{
public function __construct(
private readonly UriSigner $uriSigner,
private readonly UserRepository $userRepository,
private readonly UserAuthenticationHandler $authenticationHandler,
private readonly TotpAuthenticatorInterface $totpAuthenticator,
private readonly MailCryptKeyHandler $mailCryptKeyHandler,
private readonly RecoveryTokenHandler $recoveryTokenHandler,
private readonly EntityManagerInterface $manager,
) {
}

#[Route(
path: '/recovery/token/regenerate',
name: 'recovery_token_regenerate',
methods: ['GET'],
)]
public function show(Request $request): Response
{
$user = $this->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', [

Check failure on line 58 in src/Controller/RecoveryTokenRegenerateController.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Recovery/token_regenerate.html.twig" 5 times.

See more on https://sonarcloud.io/project/issues?id=systemli_userli&issues=AZ2h-5eHIF1tYctorEEx&open=AZ2h-5eHIF1tYctorEEx&pullRequest=1255
'form' => $form,
'user' => $user,
]);
}

#[Route(
path: '/recovery/token/regenerate',
name: 'recovery_token_regenerate_submit',
methods: ['POST'],
)]
public function submit(Request $request): Response

Check warning on line 69 in src/Controller/RecoveryTokenRegenerateController.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 5 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=systemli_userli&issues=AZ2h-5eHIF1tYctorEEv&open=AZ2h-5eHIF1tYctorEEv&pullRequest=1255
{
$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

Check warning on line 149 in src/Controller/RecoveryTokenRegenerateController.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=systemli_userli&issues=AZ2h-5eHIF1tYctorEEw&open=AZ2h-5eHIF1tYctorEEw&pullRequest=1255
{
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);
}
}
32 changes: 32 additions & 0 deletions src/Form/Model/RecoveryTokenRegenerate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Form\Model;

final class RecoveryTokenRegenerate
{
private string $password = '';

private ?string $totpCode = null;

public function getPassword(): string
{
return $this->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;
}
}
Loading
Loading