Skip to content

Commit 2beb782

Browse files
committed
Add TOTP functionality
Signed-off-by: Tim Goudriaan <tim@codedmonkey.com>
1 parent 9cc4ada commit 2beb782

22 files changed

Lines changed: 438 additions & 62 deletions

config/packages/scheb_2fa.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ scheb_two_factor:
55
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
66
totp:
77
enabled: true
8+
template: dashboard/security/mfa.html.twig
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
final class Version20250922225651 extends AbstractMigration
11+
{
12+
public function getDescription(): string
13+
{
14+
return 'Add TOTP secret';
15+
}
16+
17+
public function up(Schema $schema): void
18+
{
19+
$this->addSql(<<<'SQL'
20+
ALTER TABLE "user" ADD totp_secret VARCHAR(255) DEFAULT NULL
21+
SQL);
22+
}
23+
24+
public function down(Schema $schema): void
25+
{
26+
$this->addSql(<<<'SQL'
27+
ALTER TABLE "user" DROP totp_secret
28+
SQL);
29+
}
30+
}

src/Controller/Dashboard/DashboardAccountController.php

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
use CodedMonkey\Dirigent\Doctrine\Repository\UserRepository;
77
use CodedMonkey\Dirigent\Form\AccountFormType;
88
use CodedMonkey\Dirigent\Form\ChangePasswordFormType;
9+
use CodedMonkey\Dirigent\Form\MfaClearFormType;
10+
use CodedMonkey\Dirigent\Form\MfaSetupFormType;
11+
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
912
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
10-
use Symfony\Component\Form\FormError;
1113
use Symfony\Component\HttpFoundation\Request;
1214
use Symfony\Component\HttpFoundation\Response;
13-
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
1415
use Symfony\Component\Routing\Attribute\Route;
1516
use Symfony\Component\Security\Http\Attribute\CurrentUser;
1617
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -19,7 +20,7 @@ class DashboardAccountController extends AbstractController
1920
{
2021
public function __construct(
2122
private readonly UserRepository $userRepository,
22-
private readonly UserPasswordHasherInterface $passwordHasher,
23+
private readonly TotpAuthenticatorInterface $totpAuthenticator,
2324
) {
2425
}
2526

@@ -28,8 +29,6 @@ public function __construct(
2829
public function account(Request $request, #[CurrentUser] User $user): Response
2930
{
3031
$accountForm = $this->createForm(AccountFormType::class, $user);
31-
$passwordForm = $this->createForm(ChangePasswordFormType::class);
32-
3332
$accountForm->handleRequest($request);
3433

3534
if ($accountForm->isSubmitted() && $accountForm->isValid()) {
@@ -40,29 +39,84 @@ public function account(Request $request, #[CurrentUser] User $user): Response
4039
return $this->redirectToRoute('dashboard_account');
4140
}
4241

42+
$passwordForm = $this->createForm(ChangePasswordFormType::class);
4343
$passwordForm->handleRequest($request);
4444

45-
if ($passwordForm->isSubmitted()) {
46-
$currentPassword = $passwordForm->get('currentPassword')->getData();
45+
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
46+
$user->setPlainPassword($passwordForm->get('newPassword')->getData());
4747

48-
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
49-
$passwordForm->get('currentPassword')->addError(new FormError('Your current password is incorrect.'));
50-
}
51-
52-
if ($passwordForm->isValid()) {
53-
$user->setPlainPassword($passwordForm->get('newPassword')->getData());
54-
55-
$this->userRepository->save($user, true);
48+
$this->userRepository->save($user, true);
5649

57-
$this->addFlash('success', 'Your password was successfully updated.');
50+
$this->addFlash('success', 'Your password was successfully updated.');
5851

59-
return $this->redirectToRoute('dashboard_account');
60-
}
52+
return $this->redirectToRoute('dashboard_account');
6153
}
6254

63-
return $this->render('dashboard/account.html.twig', [
55+
return $this->render('dashboard/account/account.html.twig', [
6456
'accountForm' => $accountForm,
6557
'passwordForm' => $passwordForm,
6658
]);
6759
}
60+
61+
#[Route('/account/mfa', name: 'dashboard_account_mfa')]
62+
#[IsGranted('ROLE_USER')]
63+
public function mfa(Request $request, #[CurrentUser] User $user): Response
64+
{
65+
if ($user->isTotpAuthenticationEnabled()) {
66+
return $this->clearMfa($request, $user);
67+
}
68+
69+
return $this->setupMfa($request, $user);
70+
}
71+
72+
private function setupMfa(Request $request, User $user): Response
73+
{
74+
$session = $request->getSession();
75+
76+
if (null === $totpSecret = $session->get('totp_secret')) {
77+
$totpSecret = $this->totpAuthenticator->generateSecret();
78+
79+
$session->set('totp_secret', $totpSecret);
80+
}
81+
82+
$user->setTotpSecret($totpSecret);
83+
84+
$form = $this->createForm(MfaSetupFormType::class);
85+
$form->handleRequest($request);
86+
87+
if ($form->isSubmitted() && $form->isValid()) {
88+
$this->userRepository->save($user, true);
89+
90+
$this->addFlash('success', 'Multi-factor authentication was successfully enabled.');
91+
92+
$session->remove('totp_secret');
93+
94+
return $this->redirectToRoute('dashboard_account');
95+
}
96+
97+
return $this->render('dashboard/account/mfa_setup.html.twig', [
98+
'form' => $form,
99+
'totpContent' => $this->totpAuthenticator->getQRContent($user),
100+
]);
101+
}
102+
103+
public function clearMfa(Request $request, User $user): Response
104+
{
105+
$form = $this->createForm(MfaClearFormType::class);
106+
$form->handleRequest($request);
107+
108+
if ($form->isSubmitted() && $form->isValid()) {
109+
$user->setTotpSecret(null);
110+
111+
$this->userRepository->save($user, true);
112+
113+
$this->addFlash('warning', 'Multi-factor authentication was successfully disabled.');
114+
115+
return $this->redirectToRoute('dashboard_account');
116+
}
117+
118+
return $this->render('dashboard/account/mfa_clear.html.twig', [
119+
'form' => $form,
120+
]);
121+
}
68122
}

src/Doctrine/Entity/User.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
use Doctrine\ORM\Mapping\GeneratedValue;
99
use Doctrine\ORM\Mapping\Id;
1010
use Doctrine\ORM\Mapping\Table;
11+
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
12+
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
13+
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
1114
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
1215
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
1316
use Symfony\Component\Security\Core\User\UserInterface;
1417

1518
#[Entity(repositoryClass: UserRepository::class)]
1619
#[Table(name: '`user`')]
1720
#[UniqueEntity('username', message: 'This username is already taken')]
18-
class User implements UserInterface, PasswordAuthenticatedUserInterface
21+
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
1922
{
2023
#[Column]
2124
#[GeneratedValue]
@@ -39,6 +42,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
3942

4043
private ?string $plainPassword = null;
4144

45+
#[Column(nullable: true)]
46+
private ?string $totpSecret = null;
47+
4248
public function getId(): ?int
4349
{
4450
return $this->id;
@@ -110,6 +116,16 @@ public function setPlainPassword(string $password): self
110116
return $this;
111117
}
112118

119+
public function getTotpSecret(): ?string
120+
{
121+
return $this->totpSecret;
122+
}
123+
124+
public function setTotpSecret(?string $totpSecret): void
125+
{
126+
$this->totpSecret = $totpSecret;
127+
}
128+
113129
public function getUserIdentifier(): string
114130
{
115131
return (string) $this->username;
@@ -160,4 +176,19 @@ public function setSuperAdmin(bool $admin): void
160176
}
161177
}
162178
}
179+
180+
public function isTotpAuthenticationEnabled(): bool
181+
{
182+
return null !== $this->totpSecret;
183+
}
184+
185+
public function getTotpAuthenticationUsername(): string
186+
{
187+
return $this->username;
188+
}
189+
190+
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
191+
{
192+
return $this->totpSecret ? new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6) : null;
193+
}
163194
}

src/EventListener/DashboardRoutingListener.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
public function dashboardContext(RequestEvent $event): void
1717
{
1818
$request = $event->getRequest();
19+
$routeName = $request->attributes->get('_route');
1920

20-
if (str_starts_with($request->attributes->get('_route'), 'dashboard_')) {
21+
if (str_starts_with($routeName, 'dashboard_') || 'mfa_login' === $routeName) {
2122
$request->attributes->set(EA::DASHBOARD_CONTROLLER_FQCN, DashboardRootController::class);
2223
}
2324
}

src/Form/ChangePasswordFormType.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace CodedMonkey\Dirigent\Form;
44

5+
use CodedMonkey\Dirigent\Validator\UserPassword;
56
use Symfony\Component\Form\AbstractType;
67
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
78
use Symfony\Component\Form\FormBuilderInterface;
@@ -13,6 +14,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
1314
$builder
1415
->add('currentPassword', PasswordType::class, [
1516
'required' => true,
17+
'constraints' => [new UserPassword()],
1618
])
1719
->add('newPassword', NewPasswordType::class, [
1820
'new_password' => true,

src/Form/MfaClearFormType.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace CodedMonkey\Dirigent\Form;
4+
5+
use CodedMonkey\Dirigent\Validator\UserPassword;
6+
use Symfony\Component\Form\AbstractType;
7+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
8+
use Symfony\Component\Form\FormBuilderInterface;
9+
10+
class MfaClearFormType extends AbstractType
11+
{
12+
public function buildForm(FormBuilderInterface $builder, array $options): void
13+
{
14+
$builder
15+
->add('currentPassword', PasswordType::class, [
16+
'required' => true,
17+
'constraints' => [new UserPassword()],
18+
]);
19+
}
20+
}

src/Form/MfaSetupFormType.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace CodedMonkey\Dirigent\Form;
4+
5+
use CodedMonkey\Dirigent\Validator\UserMfaCode;
6+
use CodedMonkey\Dirigent\Validator\UserPassword;
7+
use Symfony\Component\Form\AbstractType;
8+
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
9+
use Symfony\Component\Form\FormBuilderInterface;
10+
11+
class MfaSetupFormType extends AbstractType
12+
{
13+
public function buildForm(FormBuilderInterface $builder, array $options): void
14+
{
15+
$builder
16+
->add('currentPassword', PasswordType::class, [
17+
'required' => true,
18+
'constraints' => [new UserPassword()],
19+
])
20+
->add('totpCode', TotpCodeType::class, [
21+
'label' => 'auth_code',
22+
'translation_domain' => 'SchebTwoFactorBundle',
23+
'constraints' => [new UserMfaCode()],
24+
]);
25+
}
26+
}

src/Form/NewPasswordType.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public static function constraints(bool $nullable = true): array
5151
$constraints = [
5252
new Length([
5353
'min' => 8,
54-
'minMessage' => 'Your password should be at least {{ limit }} characters',
54+
'minMessage' => 'Your password must be at least {{ limit }} characters',
5555
'max' => 4096, // max length allowed by Symfony for security reasons
5656
]),
5757
new PasswordStrength(minScore: PasswordStrength::STRENGTH_WEAK),
@@ -60,7 +60,7 @@ public static function constraints(bool $nullable = true): array
6060

6161
if (!$nullable) {
6262
$constraints[] = new NotBlank([
63-
'message' => 'Please enter a password',
63+
'message' => 'Enter a password',
6464
]);
6565
}
6666

src/Form/TotpCodeType.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace CodedMonkey\Dirigent\Form;
4+
5+
use Symfony\Component\Form\AbstractType;
6+
use Symfony\Component\Form\Extension\Core\Type\TextType;
7+
use Symfony\Component\Form\FormInterface;
8+
use Symfony\Component\Form\FormView;
9+
use Symfony\Component\OptionsResolver\OptionsResolver;
10+
11+
class TotpCodeType extends AbstractType
12+
{
13+
public function buildView(FormView $view, FormInterface $form, array $options): void
14+
{
15+
$view->vars['value'] = '';
16+
}
17+
18+
public function configureOptions(OptionsResolver $resolver): void
19+
{
20+
$resolver->setDefaults([
21+
'attr' => [
22+
'autocomplete' => 'one-time-code',
23+
'inputmode' => 'numeric',
24+
'pattern' => '[0-9]*',
25+
],
26+
]);
27+
}
28+
29+
public function getParent(): string
30+
{
31+
return TextType::class;
32+
}
33+
}

0 commit comments

Comments
 (0)