Skip to content

Commit 711350f

Browse files
committed
Add TOTP functionality
Signed-off-by: Tim Goudriaan <tim@codedmonkey.com>
1 parent b3c63bd commit 711350f

8 files changed

Lines changed: 204 additions & 7 deletions

File tree

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 Version20250409225651 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: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
use CodedMonkey\Dirigent\Doctrine\Entity\User;
66
use CodedMonkey\Dirigent\Doctrine\Repository\UserRepository;
77
use CodedMonkey\Dirigent\Form\AccountFormType;
8+
use CodedMonkey\Dirigent\Form\AccountMfaFormType;
89
use CodedMonkey\Dirigent\Form\ChangePasswordFormType;
910
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
11+
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
1012
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1113
use Symfony\Component\Form\FormError;
1214
use Symfony\Component\HttpFoundation\Request;
@@ -28,6 +30,7 @@ public static function getSubscribedServices(): array
2830
public function __construct(
2931
private readonly UserRepository $userRepository,
3032
private readonly UserPasswordHasherInterface $passwordHasher,
33+
private readonly TotpAuthenticatorInterface $totpAuthenticator,
3134
) {
3235
}
3336

@@ -36,8 +39,6 @@ public function __construct(
3639
public function account(Request $request, #[CurrentUser] User $user): Response
3740
{
3841
$accountForm = $this->createForm(AccountFormType::class, $user);
39-
$passwordForm = $this->createForm(ChangePasswordFormType::class);
40-
4142
$accountForm->handleRequest($request);
4243

4344
if ($accountForm->isSubmitted() && $accountForm->isValid()) {
@@ -50,6 +51,7 @@ public function account(Request $request, #[CurrentUser] User $user): Response
5051
return $this->redirect($url);
5152
}
5253

54+
$passwordForm = $this->createForm(ChangePasswordFormType::class);
5355
$passwordForm->handleRequest($request);
5456

5557
if ($passwordForm->isSubmitted()) {
@@ -77,4 +79,57 @@ public function account(Request $request, #[CurrentUser] User $user): Response
7779
'passwordForm' => $passwordForm,
7880
]);
7981
}
82+
83+
#[Route('/dashboard/account/mfa', name: 'dashboard_account_mfa')]
84+
#[IsGranted('ROLE_USER')]
85+
public function mfa(Request $request, #[CurrentUser] User $user): Response
86+
{
87+
if (!$user->isTotpAuthenticationEnabled()) {
88+
$session = $request->getSession();
89+
90+
if (null === $totpSecret = $session->get('totp_secret')) {
91+
$totpSecret = $this->totpAuthenticator->generateSecret();
92+
93+
$session->set('totp_secret', $totpSecret);
94+
}
95+
96+
$form = $this->createForm(AccountMfaFormType::class);
97+
$form->handleRequest($request);
98+
99+
if ($form->isSubmitted()) {
100+
$currentPassword = $form->get('currentPassword')->getData();
101+
102+
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
103+
$form->get('currentPassword')->addError(new FormError('Your current password is incorrect.'));
104+
}
105+
106+
$user->setTotpSecret($totpSecret);
107+
108+
$totpCode = $form->get('totpCode')->getData();
109+
110+
if (!$this->totpAuthenticator->checkCode($user, $totpCode)) {
111+
$user->setTotpSecret(null);
112+
113+
$form->get('totpCode')->addError(new FormError('The verification code is incorrect.'));
114+
}
115+
116+
if ($form->isValid()) {
117+
$this->userRepository->save($user, true);
118+
119+
$this->addFlash('success', 'Multi-factor authentication was successfully enabled.');
120+
121+
$session->remove('totp_secret');
122+
123+
$url = $this->container->get(AdminUrlGenerator::class)->setRoute('dashboard_account')->generateUrl();
124+
125+
return $this->redirect($url);
126+
}
127+
}
128+
129+
return $this->render('dashboard/account/mfa.html.twig', [
130+
'form' => $form,
131+
'totpSecret' => $totpSecret,
132+
]);
133+
}
134+
}
80135
}

src/Doctrine/Entity/User.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
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\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
1215
use Symfony\Component\Security\Core\User\UserInterface;
1316

1417
#[Entity(repositoryClass: UserRepository::class)]
1518
#[Table(name: '`user`')]
16-
class User implements UserInterface, PasswordAuthenticatedUserInterface
19+
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
1720
{
1821
#[Column]
1922
#[GeneratedValue]
@@ -37,6 +40,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
3740

3841
private ?string $plainPassword = null;
3942

43+
#[Column(nullable: true)]
44+
private ?string $totpSecret = null;
45+
4046
public function getId(): ?int
4147
{
4248
return $this->id;
@@ -108,6 +114,16 @@ public function setPlainPassword(string $password): self
108114
return $this;
109115
}
110116

117+
public function getTotpSecret(): ?string
118+
{
119+
return $this->totpSecret;
120+
}
121+
122+
public function setTotpSecret(?string $totpSecret): void
123+
{
124+
$this->totpSecret = $totpSecret;
125+
}
126+
111127
public function getUserIdentifier(): string
112128
{
113129
return (string) $this->username;
@@ -158,4 +174,19 @@ public function setSuperAdmin(bool $admin): void
158174
}
159175
}
160176
}
177+
178+
public function isTotpAuthenticationEnabled(): bool
179+
{
180+
return null !== $this->totpSecret;
181+
}
182+
183+
public function getTotpAuthenticationUsername(): string
184+
{
185+
return $this->username;
186+
}
187+
188+
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
189+
{
190+
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 20, 8);
191+
}
161192
}

src/Form/AccountMfaFormType.php

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

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

templates/dashboard/account.html.twig

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,17 @@
4141
<div class="form-fieldset field-form_fieldset">
4242
<div class="form-fieldset-header with-help">
4343
<div class="form-fieldset-title">
44-
<span class="not-collapsible form-fieldset-title-content">Multi-Factor Authentication</span>
45-
<div class="form-fieldset-help">Add an extra layer of security</div>
44+
<span class="not-collapsible form-fieldset-title-content">{{ 'Multi-Factor Authentication'|trans }}</span>
45+
<div class="form-fieldset-help">{{ 'account.mfa.help'|trans }}</div>
4646
</div>
4747
</div>
4848

4949
<div class="form-fieldset-body show">
5050
<div class="row">
5151
<div class="col-md-6 col-xxl-5">
52-
todo
52+
<a href="{{ path('dashboard_account_mfa') }}" class="btn btn-primary">{{ 'account.mfa.enable'|trans }}</a>
5353
</div>
5454
</div>
5555
</div>
5656
</div>
57-
5857
{% endblock %}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% extends '@EasyAdmin/page/content.html.twig' %}
2+
3+
{% block page_title %}{{ 'Multi-Factor Authentication'|trans }}{% endblock %}
4+
5+
{% block page_content %}
6+
<div class="row">
7+
<div class="col-md-6 col-xxl-5">
8+
<div class="mb-3">
9+
<label for="totp_secret" class="form-label required">{{ 'MFA secret' }}</label>
10+
<input id="totp_secret" type="text" value="{{ totpSecret }}" class="form-control">
11+
</div>
12+
13+
{{ form_start(form) }}
14+
{{ form_rest(form) }}
15+
16+
<div class="form-group">
17+
<button class="btn btn-primary">{{ 'account.mfa.enable'|trans }}</button>
18+
</div>
19+
{{ form_end(form) }}
20+
</div>
21+
</div>
22+
{% endblock %}

translations/messages.en.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Description: Description
2525
Dynamic Update Delay: Dynamic Update Delay
2626
Email: Email
2727
Expires At: Expires at
28+
Multi-Factor Authentication: Multi-Factor Authentication
2829
New password: New password
2930
Name: Name
3031
Package Mirroring: Package Mirroring
@@ -69,3 +70,8 @@ registry:
6970
none: Package mirroring disabled
7071
manual: Only mirror specified packages
7172
auto: Automatically mirror packages on request
73+
74+
account:
75+
mfa:
76+
enable: Enable MFA authentication
77+
help: Secure your account with a time-based code from an authenticator app.

0 commit comments

Comments
 (0)