Skip to content

Commit a564062

Browse files
authored
On password reset require 2FA code before storing new password (#1403)
* Two-Factor: extract code validation into constraint and add test to enable two-factor * Two-Factor: password reset for a user with 2FA enabled should ask for the 2FA code * Apply review feedback: - better form options - define listener event on method instead of class - avoid initializing new totp secret on every request * TwoFactor: move constraint and validator to existing classes
1 parent 1e3cbab commit a564062

14 files changed

+409
-29
lines changed

.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ PANTHER_APP_ENV=panther
66
DATABASE_URL="mysql://[email protected]:3306/packagist?serverVersion=8.0.28"
77
MAILER_DSN=null://null
88
REDIS_URL=redis://localhost/14
9+
APP_MAILER_FROM_EMAIL=[email protected]

config/services_test.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ services:
1313
Algolia\AlgoliaSearch\SearchClient:
1414
public: true
1515
factory: ['Algolia\AlgoliaSearch\SearchClient', create]
16+
17+
# stub to replace 2FA code generation
18+
App\Tests\Mock\TotpAuthenticatorStub:
19+
arguments:
20+
$totpFactory: '@scheb_two_factor.security.totp_factory'
21+
Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface: '@App\Tests\Mock\TotpAuthenticatorStub'

src/Controller/ResetPasswordController.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Form\ResetPasswordFormType;
1717
use App\Form\ResetPasswordRequestFormType;
1818
use App\Security\BruteForceLoginFormAuthenticator;
19+
use App\Security\Passport\Badge\ResolvedTwoFactorCodeCredentials;
1920
use App\Security\UserChecker;
2021
use Beelab\Recaptcha2Bundle\Recaptcha\RecaptchaException;
2122
use Beelab\Recaptcha2Bundle\Recaptcha\RecaptchaVerifier;
@@ -98,7 +99,7 @@ public function reset(Request $request, UserPasswordHasherInterface $passwordHas
9899
}
99100

100101
// The token is valid; allow the user to change their password.
101-
$form = $this->createForm(ResetPasswordFormType::class);
102+
$form = $this->createForm(ResetPasswordFormType::class, null, ['user' => $user]);
102103
$form->handleRequest($request);
103104

104105
if ($form->isSubmitted() && $form->isValid()) {
@@ -123,7 +124,9 @@ public function reset(Request $request, UserPasswordHasherInterface $passwordHas
123124
// skip authenticating if any pre-auth check does not pass
124125
}
125126

126-
if ($response = $userAuthenticator->authenticateUser($user, $authenticator, $request)) {
127+
// A user resetting the password with 2FA enabled, should automatically be marked as 2FA complete
128+
$badges = $user->isTotpAuthenticationEnabled() ? [new ResolvedTwoFactorCodeCredentials()] : [];
129+
if ($response = $userAuthenticator->authenticateUser($user, $authenticator, $request, $badges)) {
127130
return $response;
128131
}
129132

src/Controller/UserController.php

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -265,39 +265,28 @@ public function enableTwoFactorAuthAction(Request $req, #[VarName('name')] User
265265
throw $this->createAccessDeniedException('You cannot change this user\'s two-factor authentication settings');
266266
}
267267

268-
$enableRequest = new EnableTwoFactorRequest();
269-
$form = $this->createForm(EnableTwoFactorAuthType::class, $enableRequest)
270-
->handleRequest($req);
271-
272-
$secret = (string) $req->getSession()->get('2fa_secret');
273-
if (!$form->isSubmitted() || '' === $secret) {
274-
$secret = $authenticator->generateSecret();
275-
$req->getSession()->set('2fa_secret', $secret);
276-
}
277-
268+
$secret = (string) $req->getSession()->get('2fa_secret') ?: $authenticator->generateSecret();
278269
// Temporarily store this code on the user, as we'll need it there to generate the
279270
// QR code and to check the confirmation code. We won't actually save this change
280271
// until we've confirmed the code
281272
$user->setTotpSecret($secret);
282273

283-
if ($form->isSubmitted()) {
284-
// Validate the code using the secret that was submitted in the form
285-
if (!$authenticator->checkCode($user, $enableRequest->getCode() ?? '')) {
286-
$form->get('code')->addError(new FormError('Invalid authenticator code'));
287-
}
274+
$enableRequest = new EnableTwoFactorRequest();
275+
$form = $this->createForm(EnableTwoFactorAuthType::class, $enableRequest, ['user' => $user])
276+
->handleRequest($req);
288277

289-
if ($form->isValid()) {
290-
$req->getSession()->remove('2fa_secret');
291-
$authManager->enableTwoFactorAuth($user, $secret);
292-
$backupCode = $authManager->generateAndSaveNewBackupCode($user);
278+
if ($form->isSubmitted() && $form->isValid()) {
279+
$req->getSession()->remove('2fa_secret');
280+
$authManager->enableTwoFactorAuth($user, $secret);
281+
$backupCode = $authManager->generateAndSaveNewBackupCode($user);
293282

294-
$this->addFlash('success', 'Two-factor authentication has been enabled.');
295-
$req->getSession()->set('backup_code', $backupCode);
283+
$this->addFlash('success', 'Two-factor authentication has been enabled.');
284+
$req->getSession()->set('backup_code', $backupCode);
296285

297-
return $this->redirectToRoute('user_2fa_confirm', ['name' => $user->getUsername()]);
298-
}
286+
return $this->redirectToRoute('user_2fa_confirm', ['name' => $user->getUsername()]);
299287
}
300288

289+
$req->getSession()->set('2fa_secret', $secret);
301290
$qrContent = $authenticator->getQRContent($user);
302291

303292
$qrCode = Builder::create()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\EventListener;
14+
15+
use App\Security\Passport\Badge\ResolvedTwoFactorCodeCredentials;
16+
use Scheb\TwoFactorBundle\Security\Http\Authenticator\TwoFactorAuthenticator;
17+
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
18+
use Symfony\Component\Security\Http\Event\AuthenticationTokenCreatedEvent;
19+
20+
class ResolvedTwoFactorCodeCredentialsListener
21+
{
22+
#[AsEventListener(event: AuthenticationTokenCreatedEvent::class, priority: 512)]
23+
public function onAuthenticationTokenCreated(AuthenticationTokenCreatedEvent $event): void
24+
{
25+
if ($event->getPassport()->getBadge(ResolvedTwoFactorCodeCredentials::class)) {
26+
$event->getAuthenticatedToken()->setAttribute(TwoFactorAuthenticator::FLAG_2FA_COMPLETE, true);
27+
}
28+
}
29+
}

src/Form/ResetPasswordFormType.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,25 @@
1212

1313
namespace App\Form;
1414

15+
use App\Entity\User;
1516
use App\Validator\Password;
17+
use App\Validator\TwoFactorCode;
1618
use Symfony\Component\Form\AbstractType;
1719
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
20+
use Symfony\Component\Form\Extension\Core\Type\TextType;
1821
use Symfony\Component\Form\FormBuilderInterface;
22+
use Symfony\Component\OptionsResolver\OptionsResolver;
1923

2024
class ResetPasswordFormType extends AbstractType
2125
{
26+
public function configureOptions(OptionsResolver $resolver): void
27+
{
28+
$resolver
29+
->define('user')
30+
->allowedTypes(User::class)
31+
->required();
32+
}
33+
2234
public function buildForm(FormBuilderInterface $builder, array $options): void
2335
{
2436
$builder
@@ -32,5 +44,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
3244
],
3345
])
3446
;
47+
48+
if ($options['user']->isTotpAuthenticationEnabled()) {
49+
$builder
50+
->add('twoFactorCode', TextType::class, [
51+
'label' => 'Two-Factor Code',
52+
'required' => true,
53+
'mapped' => false,
54+
'constraints' => [
55+
new TwoFactorCode($options['user']),
56+
],
57+
]);
58+
}
3559
}
3660
}

src/Form/Type/EnableTwoFactorAuthType.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
namespace App\Form\Type;
1414

15+
use App\Entity\User;
1516
use App\Form\Model\EnableTwoFactorRequest;
17+
use App\Validator\TwoFactorCode;
1618
use Symfony\Component\Form\AbstractType;
1719
use Symfony\Component\Form\Extension\Core\Type\TextType;
1820
use Symfony\Component\Form\FormBuilderInterface;
@@ -25,13 +27,19 @@ class EnableTwoFactorAuthType extends AbstractType
2527
{
2628
public function buildForm(FormBuilderInterface $builder, array $options): void
2729
{
28-
$builder->add('code', TextType::class);
30+
$builder->add('code', TextType::class, [
31+
'constraints' => [new TwoFactorCode($options['user'])]
32+
]);
2933
}
3034

3135
public function configureOptions(OptionsResolver $resolver): void
3236
{
33-
$resolver->setDefaults([
34-
'data_class' => EnableTwoFactorRequest::class,
35-
]);
37+
$resolver
38+
->setDefaults([
39+
'data_class' => EnableTwoFactorRequest::class,
40+
])
41+
->define('user')
42+
->allowedTypes(User::class)
43+
->required();
3644
}
3745
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Security\Passport\Badge;
14+
15+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
16+
17+
class ResolvedTwoFactorCodeCredentials implements BadgeInterface
18+
{
19+
public function isResolved(): bool
20+
{
21+
return true;
22+
}
23+
}

src/Validator/TwoFactorCode.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Validator;
14+
15+
use App\Entity\User;
16+
use Symfony\Component\Validator\Constraint;
17+
18+
class TwoFactorCode extends Constraint
19+
{
20+
public function __construct(
21+
public readonly User $user,
22+
public readonly string $message = 'Invalid authenticator code',
23+
) {
24+
parent::__construct();
25+
}
26+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Validator;
14+
15+
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
16+
use Symfony\Component\Validator\Constraint;
17+
use Symfony\Component\Validator\ConstraintValidator;
18+
19+
class TwoFactorCodeValidator extends ConstraintValidator
20+
{
21+
public function __construct(
22+
private readonly TotpAuthenticatorInterface $totpAuthenticator,
23+
) {}
24+
25+
/**
26+
* @param TwoFactorCode $constraint
27+
*/
28+
public function validate(mixed $value, Constraint $constraint): void
29+
{
30+
if (!$this->totpAuthenticator->checkCode($constraint->user, (string) $value)) {
31+
$this->context->addViolation($constraint->message);
32+
}
33+
}
34+
}

templates/reset_password/reset.html.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
{{ form_start(resetForm) }}
99
{{ form_row(resetForm.plainPassword) }}
10+
{% if resetForm.twoFactorCode is defined %}
11+
{{ form_row(resetForm.twoFactorCode) }}
12+
{% endif %}
1013
<button class="btn btn-primary">Reset password</button>
1114
{{ form_end(resetForm) }}
1215
{% endblock %}

0 commit comments

Comments
 (0)