From 975ceaf01229c8a7f657205f99096c8444b71574 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Mon, 7 Apr 2025 00:02:45 +0200 Subject: [PATCH 01/10] Add scheb/2fa-bundle dependency --- composer.json | 1 + composer.lock | 70 +++++++++++++++++++++++++++++++++- config/bundles.php | 1 + config/packages/scheb_2fa.yaml | 5 +++ config/routes/scheb_2fa.yaml | 7 ++++ symfony.lock | 13 +++++++ 6 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 config/packages/scheb_2fa.yaml create mode 100644 config/routes/scheb_2fa.yaml diff --git a/composer.json b/composer.json index c03eb0d1..f532d4f0 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "league/commonmark": "^2.7", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.1", + "scheb/2fa-bundle": "^7.7", "sentry/sentry-symfony": "^5.2", "symfony/asset": "^7.3", "symfony/console": "^7.3", diff --git a/composer.lock b/composer.lock index b49feed5..551ded0f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3f1eace0f558e9cef31dcbc6127afb6d", + "content-hash": "8b08e896a326b62d5cec27823307da48", "packages": [ { "name": "cebe/markdown", @@ -3757,6 +3757,74 @@ ], "time": "2024-05-24T10:39:05+00:00" }, + { + "name": "scheb/2fa-bundle", + "version": "v7.11.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "06a343d14dad8cdd1670157d384738f9cfba29e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/06a343d14dad8cdd1670157d384738f9cfba29e5", + "reference": "06a343d14dad8cdd1670157d384738f9cfba29e5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/twig-bundle": "^6.4 || ^7.0" + }, + "conflict": { + "scheb/two-factor-bundle": "*" + }, + "suggest": { + "scheb/2fa-backup-code": "Emergency codes when you have no access to other methods", + "scheb/2fa-email": "Send codes by email", + "scheb/2fa-google-authenticator": "Google Authenticator support", + "scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)", + "scheb/2fa-trusted-device": "Trusted devices support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "A generic interface to implement two-factor authentication in Symfony applications", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-bundle/tree/v7.11.0" + }, + "time": "2025-06-27T12:14:20+00:00" + }, { "name": "seld/jsonlint", "version": "1.11.0", diff --git a/config/bundles.php b/config/bundles.php index acd48707..7ba8832b 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -7,6 +7,7 @@ Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Sentry\SentryBundle\SentryBundle::class => ['prod' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 00000000..8a33ebb0 --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,5 @@ +# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html +scheb_two_factor: + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml new file mode 100644 index 00000000..9a8ca667 --- /dev/null +++ b/config/routes/scheb_2fa.yaml @@ -0,0 +1,7 @@ +2fa_login: + path: /2fa + defaults: + _controller: "scheb_two_factor.form_controller::form" + +2fa_login_check: + path: /2fa_check diff --git a/symfony.lock b/symfony.lock index 832d7374..6d6caf06 100644 --- a/symfony.lock +++ b/symfony.lock @@ -134,6 +134,19 @@ "tests/bootstrap.php" ] }, + "scheb/2fa-bundle": { + "version": "7.7", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "sentry/sentry-symfony": { "version": "5.2", "recipe": { From 7fafa2cffe17a8e8b41f37b45ef243d3f7b32624 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Thu, 10 Apr 2025 00:36:17 +0200 Subject: [PATCH 02/10] Configure 2fa bundle --- config/packages/security.yaml | 8 ++++++-- config/routes/scheb_2fa.yaml | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8f8cff98..0008f3f4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -34,14 +34,18 @@ security: target: dashboard switch_user: true + two_factor: + auth_form_path: mfa_login + check_path: mfa_login_check + # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/logout, role: PUBLIC_ACCESS } + - { path: ^/mfa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } when@test: security: diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml index 9a8ca667..5ef890da 100644 --- a/config/routes/scheb_2fa.yaml +++ b/config/routes/scheb_2fa.yaml @@ -1,7 +1,7 @@ -2fa_login: - path: /2fa +mfa_login: + path: /mfa defaults: _controller: "scheb_two_factor.form_controller::form" -2fa_login_check: - path: /2fa_check +mfa_login_check: + path: /mfa-check From 9cc4ada882aec53d188c7ac9f85422563ae5ee39 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Thu, 10 Apr 2025 00:58:18 +0200 Subject: [PATCH 03/10] Add and configure scheb/2fa-totp dependency --- composer.json | 1 + composer.lock | 201 ++++++++++++++++++++++++++++++++- config/packages/scheb_2fa.yaml | 2 + 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f532d4f0..2d67b032 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.1", "scheb/2fa-bundle": "^7.7", + "scheb/2fa-totp": "^7.7", "sentry/sentry-symfony": "^5.2", "symfony/asset": "^7.3", "symfony/console": "^7.3", diff --git a/composer.lock b/composer.lock index 551ded0f..34a5c072 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8b08e896a326b62d5cec27823307da48", + "content-hash": "fd4f67eaf56c478f2502364e808f86d0", "packages": [ { "name": "cebe/markdown", @@ -3004,6 +3004,73 @@ }, "time": "2025-06-03T04:55:08+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2024-05-08T12:36:18+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -3825,6 +3892,56 @@ }, "time": "2025-06-27T12:14:20+00:00" }, + { + "name": "scheb/2fa-totp", + "version": "v7.11.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-totp.git", + "reference": "cfc7b00eb4a068d8adac71d3f2727b8a24a4f27d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-totp/zipball/cfc7b00eb4a068d8adac71d3f2727b8a24a4f27d", + "reference": "cfc7b00eb4a068d8adac71d3f2727b8a24a4f27d", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with two-factor authentication using TOTP", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "totp", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-totp/tree/v7.11.0" + }, + "time": "2025-04-20T08:38:44+00:00" + }, { "name": "seld/jsonlint", "version": "1.11.0", @@ -4189,6 +4306,88 @@ ], "time": "2025-03-03T07:47:12+00:00" }, + { + "name": "spomky-labs/otphp", + "version": "11.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", + "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0 || ^3.0", + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/deprecation-contracts": "^3.2" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0", + "infection/infection": "^0.26|^0.27|^0.28|^0.29", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5.26|^10.0|^11.0", + "qossmic/deptrac-shim": "^1.0", + "rector/rector": "^1.0", + "symfony/phpunit-bridge": "^6.1|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/11.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2024-06-12T11:22:32+00:00" + }, { "name": "symfony/asset", "version": "v7.3.0", diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml index 8a33ebb0..84176ab0 100644 --- a/config/packages/scheb_2fa.yaml +++ b/config/packages/scheb_2fa.yaml @@ -3,3 +3,5 @@ scheb_two_factor: security_tokens: - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken + totp: + enabled: true From 2beb782f73681b1f9331836d3ccba60097f23045 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Thu, 10 Apr 2025 01:45:44 +0200 Subject: [PATCH 04/10] Add TOTP functionality Signed-off-by: Tim Goudriaan --- config/packages/scheb_2fa.yaml | 1 + migrations/Version20250922225651.php | 30 ++++++ .../Dashboard/DashboardAccountController.php | 92 +++++++++++++++---- src/Doctrine/Entity/User.php | 33 ++++++- .../DashboardRoutingListener.php | 3 +- src/Form/ChangePasswordFormType.php | 2 + src/Form/MfaClearFormType.php | 20 ++++ src/Form/MfaSetupFormType.php | 26 ++++++ src/Form/NewPasswordType.php | 4 +- src/Form/TotpCodeType.php | 33 +++++++ src/Validator/UserMfaCode.php | 9 ++ src/Validator/UserMfaCodeValidator.php | 51 ++++++++++ src/Validator/UserPassword.php | 10 ++ .../dashboard/{ => account}/account.html.twig | 15 ++- .../dashboard/account/mfa_clear.html.twig | 17 ++++ .../dashboard/account/mfa_setup.html.twig | 22 +++++ templates/dashboard/security/mfa.html.twig | 38 ++++++++ .../dashboard/security/register.html.twig | 42 +++------ .../security/security_layout.html.twig | 23 +++++ translations/SchebTwoFactorBundle.en.yaml | 2 + translations/messages.en.yaml | 13 +++ translations/validators.en.yaml | 14 ++- 22 files changed, 438 insertions(+), 62 deletions(-) create mode 100644 migrations/Version20250922225651.php create mode 100644 src/Form/MfaClearFormType.php create mode 100644 src/Form/MfaSetupFormType.php create mode 100644 src/Form/TotpCodeType.php create mode 100644 src/Validator/UserMfaCode.php create mode 100644 src/Validator/UserMfaCodeValidator.php create mode 100644 src/Validator/UserPassword.php rename templates/dashboard/{ => account}/account.html.twig (73%) create mode 100644 templates/dashboard/account/mfa_clear.html.twig create mode 100644 templates/dashboard/account/mfa_setup.html.twig create mode 100644 templates/dashboard/security/mfa.html.twig create mode 100644 templates/dashboard/security/security_layout.html.twig create mode 100644 translations/SchebTwoFactorBundle.en.yaml diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml index 84176ab0..01deb3d5 100644 --- a/config/packages/scheb_2fa.yaml +++ b/config/packages/scheb_2fa.yaml @@ -5,3 +5,4 @@ scheb_two_factor: - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken totp: enabled: true + template: dashboard/security/mfa.html.twig diff --git a/migrations/Version20250922225651.php b/migrations/Version20250922225651.php new file mode 100644 index 00000000..83fa88cf --- /dev/null +++ b/migrations/Version20250922225651.php @@ -0,0 +1,30 @@ +addSql(<<<'SQL' + ALTER TABLE "user" ADD totp_secret VARCHAR(255) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "user" DROP totp_secret + SQL); + } +} diff --git a/src/Controller/Dashboard/DashboardAccountController.php b/src/Controller/Dashboard/DashboardAccountController.php index d039f8fe..9ddf2839 100644 --- a/src/Controller/Dashboard/DashboardAccountController.php +++ b/src/Controller/Dashboard/DashboardAccountController.php @@ -6,11 +6,12 @@ use CodedMonkey\Dirigent\Doctrine\Repository\UserRepository; use CodedMonkey\Dirigent\Form\AccountFormType; use CodedMonkey\Dirigent\Form\ChangePasswordFormType; +use CodedMonkey\Dirigent\Form\MfaClearFormType; +use CodedMonkey\Dirigent\Form\MfaSetupFormType; +use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -19,7 +20,7 @@ class DashboardAccountController extends AbstractController { public function __construct( private readonly UserRepository $userRepository, - private readonly UserPasswordHasherInterface $passwordHasher, + private readonly TotpAuthenticatorInterface $totpAuthenticator, ) { } @@ -28,8 +29,6 @@ public function __construct( public function account(Request $request, #[CurrentUser] User $user): Response { $accountForm = $this->createForm(AccountFormType::class, $user); - $passwordForm = $this->createForm(ChangePasswordFormType::class); - $accountForm->handleRequest($request); if ($accountForm->isSubmitted() && $accountForm->isValid()) { @@ -40,29 +39,84 @@ public function account(Request $request, #[CurrentUser] User $user): Response return $this->redirectToRoute('dashboard_account'); } + $passwordForm = $this->createForm(ChangePasswordFormType::class); $passwordForm->handleRequest($request); - if ($passwordForm->isSubmitted()) { - $currentPassword = $passwordForm->get('currentPassword')->getData(); + if ($passwordForm->isSubmitted() && $passwordForm->isValid()) { + $user->setPlainPassword($passwordForm->get('newPassword')->getData()); - if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) { - $passwordForm->get('currentPassword')->addError(new FormError('Your current password is incorrect.')); - } - - if ($passwordForm->isValid()) { - $user->setPlainPassword($passwordForm->get('newPassword')->getData()); - - $this->userRepository->save($user, true); + $this->userRepository->save($user, true); - $this->addFlash('success', 'Your password was successfully updated.'); + $this->addFlash('success', 'Your password was successfully updated.'); - return $this->redirectToRoute('dashboard_account'); - } + return $this->redirectToRoute('dashboard_account'); } - return $this->render('dashboard/account.html.twig', [ + return $this->render('dashboard/account/account.html.twig', [ 'accountForm' => $accountForm, 'passwordForm' => $passwordForm, ]); } + + #[Route('/account/mfa', name: 'dashboard_account_mfa')] + #[IsGranted('ROLE_USER')] + public function mfa(Request $request, #[CurrentUser] User $user): Response + { + if ($user->isTotpAuthenticationEnabled()) { + return $this->clearMfa($request, $user); + } + + return $this->setupMfa($request, $user); + } + + private function setupMfa(Request $request, User $user): Response + { + $session = $request->getSession(); + + if (null === $totpSecret = $session->get('totp_secret')) { + $totpSecret = $this->totpAuthenticator->generateSecret(); + + $session->set('totp_secret', $totpSecret); + } + + $user->setTotpSecret($totpSecret); + + $form = $this->createForm(MfaSetupFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->userRepository->save($user, true); + + $this->addFlash('success', 'Multi-factor authentication was successfully enabled.'); + + $session->remove('totp_secret'); + + return $this->redirectToRoute('dashboard_account'); + } + + return $this->render('dashboard/account/mfa_setup.html.twig', [ + 'form' => $form, + 'totpContent' => $this->totpAuthenticator->getQRContent($user), + ]); + } + + public function clearMfa(Request $request, User $user): Response + { + $form = $this->createForm(MfaClearFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setTotpSecret(null); + + $this->userRepository->save($user, true); + + $this->addFlash('warning', 'Multi-factor authentication was successfully disabled.'); + + return $this->redirectToRoute('dashboard_account'); + } + + return $this->render('dashboard/account/mfa_clear.html.twig', [ + 'form' => $form, + ]); + } } diff --git a/src/Doctrine/Entity/User.php b/src/Doctrine/Entity/User.php index 5fad8f91..286b090a 100644 --- a/src/Doctrine/Entity/User.php +++ b/src/Doctrine/Entity/User.php @@ -8,6 +8,9 @@ use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\Table; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; +use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -15,7 +18,7 @@ #[Entity(repositoryClass: UserRepository::class)] #[Table(name: '`user`')] #[UniqueEntity('username', message: 'This username is already taken')] -class User implements UserInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface { #[Column] #[GeneratedValue] @@ -39,6 +42,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $plainPassword = null; + #[Column(nullable: true)] + private ?string $totpSecret = null; + public function getId(): ?int { return $this->id; @@ -110,6 +116,16 @@ public function setPlainPassword(string $password): self return $this; } + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function setTotpSecret(?string $totpSecret): void + { + $this->totpSecret = $totpSecret; + } + public function getUserIdentifier(): string { return (string) $this->username; @@ -160,4 +176,19 @@ public function setSuperAdmin(bool $admin): void } } } + + public function isTotpAuthenticationEnabled(): bool + { + return null !== $this->totpSecret; + } + + public function getTotpAuthenticationUsername(): string + { + return $this->username; + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + return $this->totpSecret ? new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6) : null; + } } diff --git a/src/EventListener/DashboardRoutingListener.php b/src/EventListener/DashboardRoutingListener.php index abf5ecf3..93c31ff0 100644 --- a/src/EventListener/DashboardRoutingListener.php +++ b/src/EventListener/DashboardRoutingListener.php @@ -16,8 +16,9 @@ public function dashboardContext(RequestEvent $event): void { $request = $event->getRequest(); + $routeName = $request->attributes->get('_route'); - if (str_starts_with($request->attributes->get('_route'), 'dashboard_')) { + if (str_starts_with($routeName, 'dashboard_') || 'mfa_login' === $routeName) { $request->attributes->set(EA::DASHBOARD_CONTROLLER_FQCN, DashboardRootController::class); } } diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php index 37598fad..4fb7bba7 100644 --- a/src/Form/ChangePasswordFormType.php +++ b/src/Form/ChangePasswordFormType.php @@ -2,6 +2,7 @@ namespace CodedMonkey\Dirigent\Form; +use CodedMonkey\Dirigent\Validator\UserPassword; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\FormBuilderInterface; @@ -13,6 +14,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('currentPassword', PasswordType::class, [ 'required' => true, + 'constraints' => [new UserPassword()], ]) ->add('newPassword', NewPasswordType::class, [ 'new_password' => true, diff --git a/src/Form/MfaClearFormType.php b/src/Form/MfaClearFormType.php new file mode 100644 index 00000000..c3649000 --- /dev/null +++ b/src/Form/MfaClearFormType.php @@ -0,0 +1,20 @@ +add('currentPassword', PasswordType::class, [ + 'required' => true, + 'constraints' => [new UserPassword()], + ]); + } +} diff --git a/src/Form/MfaSetupFormType.php b/src/Form/MfaSetupFormType.php new file mode 100644 index 00000000..368bde0d --- /dev/null +++ b/src/Form/MfaSetupFormType.php @@ -0,0 +1,26 @@ +add('currentPassword', PasswordType::class, [ + 'required' => true, + 'constraints' => [new UserPassword()], + ]) + ->add('totpCode', TotpCodeType::class, [ + 'label' => 'auth_code', + 'translation_domain' => 'SchebTwoFactorBundle', + 'constraints' => [new UserMfaCode()], + ]); + } +} diff --git a/src/Form/NewPasswordType.php b/src/Form/NewPasswordType.php index c99c13ff..0bb5c7ed 100644 --- a/src/Form/NewPasswordType.php +++ b/src/Form/NewPasswordType.php @@ -51,7 +51,7 @@ public static function constraints(bool $nullable = true): array $constraints = [ new Length([ 'min' => 8, - 'minMessage' => 'Your password should be at least {{ limit }} characters', + 'minMessage' => 'Your password must be at least {{ limit }} characters', 'max' => 4096, // max length allowed by Symfony for security reasons ]), new PasswordStrength(minScore: PasswordStrength::STRENGTH_WEAK), @@ -60,7 +60,7 @@ public static function constraints(bool $nullable = true): array if (!$nullable) { $constraints[] = new NotBlank([ - 'message' => 'Please enter a password', + 'message' => 'Enter a password', ]); } diff --git a/src/Form/TotpCodeType.php b/src/Form/TotpCodeType.php new file mode 100644 index 00000000..52f174c7 --- /dev/null +++ b/src/Form/TotpCodeType.php @@ -0,0 +1,33 @@ +vars['value'] = ''; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'attr' => [ + 'autocomplete' => 'one-time-code', + 'inputmode' => 'numeric', + 'pattern' => '[0-9]*', + ], + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/src/Validator/UserMfaCode.php b/src/Validator/UserMfaCode.php new file mode 100644 index 00000000..2657e8ef --- /dev/null +++ b/src/Validator/UserMfaCode.php @@ -0,0 +1,9 @@ +context->buildViolation('code_invalid') + ->setTranslationDomain('SchebTwoFactorBundle') + ->addViolation(); + + return; + } + + if (!is_string($value)) { + throw new UnexpectedTypeException($value, 'string'); + } + + $user = $this->tokenStorage->getToken()->getUser(); + + if (!$user instanceof TwoFactorInterface) { + throw new ConstraintDefinitionException(\sprintf('The "%s" class must implement the "%s" interface.', get_debug_type($user), TwoFactorInterface::class)); + } + + if (!$this->totpAuthenticator->checkCode($user, $value)) { + $this->context->buildViolation('code_invalid') + ->setTranslationDomain('SchebTwoFactorBundle') + ->addViolation(); + } + } +} diff --git a/src/Validator/UserPassword.php b/src/Validator/UserPassword.php new file mode 100644 index 00000000..bff32f0a --- /dev/null +++ b/src/Validator/UserPassword.php @@ -0,0 +1,10 @@ +
- Multi-Factor Authentication -
Add an extra layer of security
+ {{ 'Multi-factor authentication'|trans }} +
{{ 'account.mfa.help'|trans }}
- todo + {% if app.user.totpAuthenticationEnabled %} +

{{ 'account.mfa.state-enabled'|trans }}

+ {% else %} +

{{ 'account.mfa.state-disabled'|trans }}

+ {% endif %} + + + {{ app.user.totpAuthenticationEnabled ? 'account.mfa.disable'|trans : 'account.mfa.enable'|trans }} +
- {% endblock %} diff --git a/templates/dashboard/account/mfa_clear.html.twig b/templates/dashboard/account/mfa_clear.html.twig new file mode 100644 index 00000000..0d0faf51 --- /dev/null +++ b/templates/dashboard/account/mfa_clear.html.twig @@ -0,0 +1,17 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}{{ 'Multi-factor authentication'|trans }}{% endblock %} + +{% block page_content %} +
+
+ {{ form_start(form) }} + {{ form_rest(form) }} + +
+ +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/templates/dashboard/account/mfa_setup.html.twig b/templates/dashboard/account/mfa_setup.html.twig new file mode 100644 index 00000000..c6c50f19 --- /dev/null +++ b/templates/dashboard/account/mfa_setup.html.twig @@ -0,0 +1,22 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}{{ 'Multi-factor authentication'|trans }}{% endblock %} + +{% block page_content %} +
+
+
+ + +
+ + {{ form_start(form) }} + {{ form_rest(form) }} + +
+ +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/templates/dashboard/security/mfa.html.twig b/templates/dashboard/security/mfa.html.twig new file mode 100644 index 00000000..0629ed47 --- /dev/null +++ b/templates/dashboard/security/mfa.html.twig @@ -0,0 +1,38 @@ +{% extends 'dashboard/security/security_layout.html.twig' %} + +{% block main %} +

{{ 'Multi-factor authentication'|trans }}

+ + {% if authenticationError %} +
+ {{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }} +
+ {% endif %} + +
+
+ +
+ +
+
+ + {% if isCsrfProtectionEnabled %} + + {% endif %} + +
+ + {{ 'Cancel'|trans }} +
+
+{% endblock main %} diff --git a/templates/dashboard/security/register.html.twig b/templates/dashboard/security/register.html.twig index 93925faa..1466378f 100644 --- a/templates/dashboard/security/register.html.twig +++ b/templates/dashboard/security/register.html.twig @@ -1,33 +1,15 @@ -{% extends ea.templatePath('layout') %} +{% extends 'dashboard/security/security_layout.html.twig' %} -{% block page_title ea.dashboardTitle|raw %} +{% block main %} +

Create an account

-{% block body_class 'page-login' %} + {{ form_start(form) }} + {{ form_row(form.username) }} + {{ form_row(form.email) }} + {{ form_row(form.plainPassword) }} -{% block wrapper_wrapper %} - - - -{% endblock wrapper_wrapper %} +
+ +
+ {{ form_end(form) }} +{% endblock main %} diff --git a/templates/dashboard/security/security_layout.html.twig b/templates/dashboard/security/security_layout.html.twig new file mode 100644 index 00000000..62829fd8 --- /dev/null +++ b/templates/dashboard/security/security_layout.html.twig @@ -0,0 +1,23 @@ +{% extends ea.templatePath('layout') %} + +{% block page_title ea.dashboardTitle|raw %} + +{% block body_class 'page-login' %} + +{% block wrapper_wrapper %} + + + +{% endblock wrapper_wrapper %} diff --git a/translations/SchebTwoFactorBundle.en.yaml b/translations/SchebTwoFactorBundle.en.yaml new file mode 100644 index 00000000..f872746f --- /dev/null +++ b/translations/SchebTwoFactorBundle.en.yaml @@ -0,0 +1,2 @@ +auth_code: Authentication code +code_invalid: The authentication code is incorrect. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 4aee01c7..cc03e5c0 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,12 +1,14 @@ # General labels Account: Account Administration: Administration +Cancel: Cancel Credits: Credits Dashboard: Dashboard Documentation: Documentation Info: Info Packages: Packages Personal: Personal +Sign in: Sign in Sign out: Sign out Usage: Usage @@ -26,6 +28,7 @@ Description: Description Dynamic Update Delay: Dynamic update delay Email: Email Expires At: Expires at +Multi-factor authentication: Multi-factor authentication New password: New password Name: Name Package Mirroring: Package mirroring @@ -108,3 +111,13 @@ registry: none: Package mirroring disabled manual: Only mirror specified packages auto: Automatically mirror packages on request + +account: + error: + password-incorrect: The password is incorrect. + mfa: + disable: Disable MFA authentication + enable: Enable MFA authentication + help: Secure your account with a time-based code from an authenticator app. + state-disabled: Multi-factor authentication is currently disabled. + state-enabled: Multi-factor authentication is currently enabled. diff --git a/translations/validators.en.yaml b/translations/validators.en.yaml index 9f8476b0..6bc37616 100644 --- a/translations/validators.en.yaml +++ b/translations/validators.en.yaml @@ -1,7 +1,11 @@ -Enter an email address: Enter an email address +# Email +Enter an email address: Enter an email address. -Please enter a password: Please enter a password -The password fields must match: The password fields must match -Your password should be at least {{ limit }} characters: Your password should be at least {{ limit }} characters +# Password +Enter a password: Enter a password. +The password fields must match: The password fields must match. +The password is incorrect: The password is incorrect. +Your password must be at least {{ limit }} characters: Your password must be at least {{ limit }} characters. -This username is already taken: This username is already taken +# Username +This username is already taken: This username is already taken. From 0403d9ad125b1edb6c299732a7c2ee842b6adbf8 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Fri, 19 Sep 2025 20:00:08 +0200 Subject: [PATCH 05/10] Install endroid/qr-code dependency --- composer.json | 1 + composer.lock | 178 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2d67b032..f05a2ab1 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.3", "easycorp/easyadmin-bundle": "^4.24.7", + "endroid/qr-code": "^6.0", "league/commonmark": "^2.7", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.1", diff --git a/composer.lock b/composer.lock index 34a5c072..b44948d4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fd4f67eaf56c478f2502364e808f86d0", + "content-hash": "57b6cd1a2109d28c4e1e7e320b250637", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + }, + "time": "2024-10-01T13:55:55+00:00" + }, { "name": "cebe/markdown", "version": "1.2.1", @@ -708,6 +762,56 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2240,6 +2344,78 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.0.9", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.0.9" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2025-07-13T19:59:45+00:00" + }, { "name": "guzzlehttp/psr7", "version": "2.7.1", From 453603933f0af081eb4e2a6dd0d6f99694e4af8c Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Sat, 20 Sep 2025 12:11:00 +0200 Subject: [PATCH 06/10] Implement QR code for MFA setup Signed-off-by: Tim Goudriaan --- assets/stylesheets/dashboard-theme.css | 8 ++++++ .../Dashboard/DashboardAccountController.php | 25 +++++++++++++++++++ .../dashboard/account/mfa_setup.html.twig | 4 +++ translations/messages.en.yaml | 1 + 4 files changed, 38 insertions(+) diff --git a/assets/stylesheets/dashboard-theme.css b/assets/stylesheets/dashboard-theme.css index a4ea56b4..21921fdb 100644 --- a/assets/stylesheets/dashboard-theme.css +++ b/assets/stylesheets/dashboard-theme.css @@ -69,3 +69,11 @@ display: inline-block; width: 120px; } + +body.ea-dark-scheme img.img-light { + display: none; +} + +body:not(.ea-dark-scheme) img.img-dark { + display: none; +} diff --git a/src/Controller/Dashboard/DashboardAccountController.php b/src/Controller/Dashboard/DashboardAccountController.php index 9ddf2839..30a74302 100644 --- a/src/Controller/Dashboard/DashboardAccountController.php +++ b/src/Controller/Dashboard/DashboardAccountController.php @@ -8,6 +8,8 @@ use CodedMonkey\Dirigent\Form\ChangePasswordFormType; use CodedMonkey\Dirigent\Form\MfaClearFormType; use CodedMonkey\Dirigent\Form\MfaSetupFormType; +use Endroid\QrCode\Builder\Builder as QrCodeBuilder; +use Endroid\QrCode\Color\Color; use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -119,4 +121,27 @@ public function clearMfa(Request $request, User $user): Response 'form' => $form, ]); } + + #[Route('/account/mfa/qr-code', name: 'dashboard_account_mfa_qr_code', methods: ['GET'])] + #[IsGranted('ROLE_USER')] + public function mfaQrCode(Request $request, #[CurrentUser] User $user): Response + { + $session = $request->getSession(); + + if (null === $totpSecret = $session->get('totp_secret')) { + throw $this->createAccessDeniedException(); + } + + $darkMode = 'dark' === $request->query->getString('mode', 'light'); + + $user->setTotpSecret($totpSecret); + + $builder = new QrCodeBuilder(data: $this->totpAuthenticator->getQRContent($user)); + $result = $builder->build( + foregroundColor: $darkMode ? new Color(212, 212, 212) : null, + backgroundColor: $darkMode ? new Color(17, 21, 23) : null, + ); + + return new Response($result->getString(), 200, ['Content-Type' => 'image/png']); + } } diff --git a/templates/dashboard/account/mfa_setup.html.twig b/templates/dashboard/account/mfa_setup.html.twig index c6c50f19..a579bc14 100644 --- a/templates/dashboard/account/mfa_setup.html.twig +++ b/templates/dashboard/account/mfa_setup.html.twig @@ -5,6 +5,10 @@ {% block page_content %}
+
+ {{ 'account.mfa.qr-code-alt'|trans }} + {{ 'account.mfa.qr-code-alt'|trans }} +
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index cc03e5c0..4ccdcabf 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -119,5 +119,6 @@ account: disable: Disable MFA authentication enable: Enable MFA authentication help: Secure your account with a time-based code from an authenticator app. + qr-code-alt: QR-code containing the MFA secret. state-disabled: Multi-factor authentication is currently disabled. state-enabled: Multi-factor authentication is currently enabled. From d0355a1f43a063462c5ab897e00df132a2ac4d4b Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Mon, 22 Sep 2025 22:22:07 +0200 Subject: [PATCH 07/10] Create tests for MFA pages Signed-off-by: Tim Goudriaan --- .../DashboardAccountControllerTest.php | 181 ++++++++++++++++++ .../DashboardSecurityControllerTest.php | 6 +- tests/Helper/MockEntityFactoryTrait.php | 25 +++ 3 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 tests/FunctionalTests/Controller/Dashboard/DashboardAccountControllerTest.php diff --git a/tests/FunctionalTests/Controller/Dashboard/DashboardAccountControllerTest.php b/tests/FunctionalTests/Controller/Dashboard/DashboardAccountControllerTest.php new file mode 100644 index 00000000..8f2512fe --- /dev/null +++ b/tests/FunctionalTests/Controller/Dashboard/DashboardAccountControllerTest.php @@ -0,0 +1,181 @@ +request('GET', '/account/mfa'); + + $this->assertResponseRedirects('/login', Response::HTTP_FOUND); + } + + public function testMfaSetup(): void + { + $client = static::createClient(); + $totpFactory = $this->getService(TotpFactory::class, 'scheb_two_factor.security.totp_factory'); + + $user = $this->createMockUser(); + $this->persistEntities($user); + + $client->loginUser($user); + + $client->request('GET', '/account/mfa'); + + $client->submitForm('Enable MFA authentication', [ + 'mfa_setup_form[currentPassword]' => 'PlainPassword99', + 'mfa_setup_form[totpCode]' => $totpFactory->createTotpForUser($user)->now(), + ]); + + $this->assertResponseRedirects('/account', Response::HTTP_FOUND); + + $this->clearEntities(); + $user = $this->getService(EntityManagerInterface::class)->find(User::class, $user->getId()); + + $this->assertNotNull($user->getTotpSecret()); + } + + public function testMfaSetupWrongPassword(): void + { + $client = static::createClient(); + $totpFactory = $this->getService(TotpFactory::class, 'scheb_two_factor.security.totp_factory'); + + $user = $this->createMockUser(); + $this->persistEntities($user); + + $client->loginUser($user); + + $client->request('GET', '/account/mfa'); + + $client->submitForm('Enable MFA authentication', [ + 'mfa_setup_form[currentPassword]' => 'OddPassword11', + 'mfa_setup_form[totpCode]' => $totpFactory->createTotpForUser($user)->now(), + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->clearEntities(); + $user = $this->getService(EntityManagerInterface::class)->find(User::class, $user->getId()); + + $this->assertNull($user->getTotpSecret()); + } + + public function testMfaSetupWrongTotpCode(): void + { + $client = static::createClient(); + + $user = $this->createMockUser(); + $this->persistEntities($user); + + $client->loginUser($user); + + $client->request('GET', '/account/mfa'); + + $client->submitForm('Enable MFA authentication', [ + 'mfa_setup_form[currentPassword]' => 'PlainPassword99', + 'mfa_setup_form[totpCode]' => 'abcdef', + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->clearEntities(); + $user = $this->getService(EntityManagerInterface::class)->find(User::class, $user->getId()); + + $this->assertNull($user->getTotpSecret()); + } + + public function testMfaClear(): void + { + $client = static::createClient(); + + $user = $this->createMockUser(mfaEnabled: true); + $this->persistEntities($user); + + $client->loginUser($user); + + $client->request('GET', '/account/mfa'); + + $client->submitForm('Disable MFA authentication', [ + 'mfa_clear_form[currentPassword]' => 'PlainPassword99', + ]); + + $this->assertResponseRedirects('/account', Response::HTTP_FOUND); + + $this->clearEntities(); + $user = $this->getService(EntityManagerInterface::class)->find(User::class, $user->getId()); + + $this->assertNull($user->getTotpSecret()); + } + + public function testMfaClearWrongPassword(): void + { + $client = static::createClient(); + + $user = $this->createMockUser(mfaEnabled: true); + $this->persistEntities($user); + + $client->loginUser($user); + + $client->request('GET', '/account/mfa'); + + $client->submitForm('Disable MFA authentication', [ + 'mfa_clear_form[currentPassword]' => 'OddPassword11', + ]); + + $this->assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->clearEntities(); + $user = $this->getService(EntityManagerInterface::class)->find(User::class, $user->getId()); + + $this->assertNotNull($user->getTotpSecret()); + } + + public function testMfaQrCode(): void + { + $client = static::createClient(); + $this->loginUser(); + + // Set TOTP secret to session + $client->request('GET', '/account/mfa'); + + $client->request('GET', '/account/mfa/qr-code'); + + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + } + + public function testMfaQrCodeUnauthenticated(): void + { + $client = static::createClient(); + + $client->request('GET', '/account/mfa/qr-code'); + + $this->assertResponseRedirects('/login', Response::HTTP_FOUND); + } + + public function testMfaQrCodeNoSetup(): void + { + $client = static::createClient(); + $this->loginUser(); + + $client->request('GET', '/account/mfa/qr-code'); + + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + } +} diff --git a/tests/FunctionalTests/Controller/Dashboard/DashboardSecurityControllerTest.php b/tests/FunctionalTests/Controller/Dashboard/DashboardSecurityControllerTest.php index 39ef4d7f..a641a037 100644 --- a/tests/FunctionalTests/Controller/Dashboard/DashboardSecurityControllerTest.php +++ b/tests/FunctionalTests/Controller/Dashboard/DashboardSecurityControllerTest.php @@ -18,10 +18,6 @@ public function testLoginRedirect(): void $client = static::createClient(); $client->request('GET', '/'); - $this->assertResponseStatusCodeSame(Response::HTTP_FOUND); - - $client->followRedirect(); - - self::assertSame('/login', $client->getRequest()->getRequestUri()); + $this->assertResponseRedirects('/login', Response::HTTP_FOUND); } } diff --git a/tests/Helper/MockEntityFactoryTrait.php b/tests/Helper/MockEntityFactoryTrait.php index cefccfd2..944c38d3 100644 --- a/tests/Helper/MockEntityFactoryTrait.php +++ b/tests/Helper/MockEntityFactoryTrait.php @@ -3,9 +3,11 @@ namespace CodedMonkey\Dirigent\Tests\Helper; use CodedMonkey\Dirigent\Doctrine\Entity\Package; +use CodedMonkey\Dirigent\Doctrine\Entity\User; use CodedMonkey\Dirigent\Doctrine\Entity\Version; use Composer\Semver\VersionParser; use Doctrine\ORM\EntityManagerInterface; +use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticator; trait MockEntityFactoryTrait { @@ -17,6 +19,22 @@ protected function createMockPackage(): Package return $package; } + protected function createMockUser(bool $mfaEnabled = false): User + { + $user = new User(); + + $user->setUsername(uniqid()); + $user->setPlainPassword('PlainPassword99'); + + if ($mfaEnabled) { + $totpAuthenticator = $this->getService(TotpAuthenticator::class); + + $user->setTotpSecret($totpAuthenticator->generateSecret()); + } + + return $user; + } + protected function createMockVersion(Package $package, string $versionName = '1.0.0'): Version { $version = new Version(); @@ -46,4 +64,11 @@ protected function persistEntities(...$entities): void $entityManager->flush(); } + + protected function clearEntities(): void + { + $entityManager = $this->getService(EntityManagerInterface::class); + + $entityManager->clear(); + } } From ae457bc60eb9de96646354642baaa4b35cca8cb6 Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Thu, 9 Oct 2025 13:58:10 +0200 Subject: [PATCH 08/10] Fix the slug variable in Twig templates with extra compiler pass Signed-off-by: Tim Goudriaan --- config/packages/twig.yaml | 4 --- config/services.yaml | 2 -- .../Compiler/ParametersPass.php | 25 +++++++++++++++++++ src/DependencyInjection/DirigentExtension.php | 6 ++--- src/Kernel.php | 5 ++-- 5 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 src/DependencyInjection/Compiler/ParametersPass.php diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 900abd7c..0b0ddbe4 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -2,10 +2,6 @@ twig: file_name_pattern: '*.twig' form_themes: ['bootstrap_5_layout.html.twig'] - globals: - dirigent: - slug: '%dirigent.slug%' - when@test: twig: strict_variables: true diff --git a/config/services.yaml b/config/services.yaml index ac5b5421..fd339bf6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -3,8 +3,6 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration -parameters: - dirigent.slug: ~ services: # default configuration for services in *this* file diff --git a/src/DependencyInjection/Compiler/ParametersPass.php b/src/DependencyInjection/Compiler/ParametersPass.php new file mode 100644 index 00000000..0e9879ba --- /dev/null +++ b/src/DependencyInjection/Compiler/ParametersPass.php @@ -0,0 +1,25 @@ +setTwigGlobal($container); + } + + private function setTwigGlobal(ContainerBuilder $container): void + { + $parameterBag = $container->getParameterBag(); + + $variables = [ + 'slug' => $parameterBag->get('dirigent.slug'), + ]; + + $container->getDefinition('twig')->addMethodCall('addGlobal', ['dirigent', $variables]); + } +} diff --git a/src/DependencyInjection/DirigentExtension.php b/src/DependencyInjection/DirigentExtension.php index 824f814c..aa09d7ef 100644 --- a/src/DependencyInjection/DirigentExtension.php +++ b/src/DependencyInjection/DirigentExtension.php @@ -11,10 +11,8 @@ class DirigentExtension extends ConfigurableExtension { protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void { - if (null === $slug = $mergedConfig['slug']) { - $slug = (new AsciiSlugger())->slug($mergedConfig['title']); - $slug = strtolower($slug); - } + $slug = $mergedConfig['slug']; + $slug ??= (new AsciiSlugger())->slug($mergedConfig['title'])->lower()->toString(); $container->setParameter('dirigent.title', $mergedConfig['title']); $container->setParameter('dirigent.slug', $slug); diff --git a/src/Kernel.php b/src/Kernel.php index 2969fcc1..ebc5ac69 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -3,6 +3,7 @@ namespace CodedMonkey\Dirigent; use CodedMonkey\Dirigent\DependencyInjection\Compiler\EncryptionPass; +use CodedMonkey\Dirigent\DependencyInjection\Compiler\ParametersPass; use CodedMonkey\Dirigent\DependencyInjection\DirigentExtension; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -32,8 +33,8 @@ protected function build(ContainerBuilder $container): void { $container->registerExtension(new DirigentExtension()); - // The encryption pass has to be the first pass to run as it removes sensitive data from the container - $container->addCompilerPass(new EncryptionPass(), priority: 2048); + $container->addCompilerPass(new EncryptionPass(), priority: 2048); // The encryption pass has to be the first pass to run as it removes sensitive data from the container + $container->addCompilerPass(new ParametersPass()); } public function boot(): void From d239891b6958c95486bd8b046ed155c3a8f77dcc Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Thu, 9 Oct 2025 14:32:35 +0200 Subject: [PATCH 09/10] Set issuer label in MFA code to the application title Signed-off-by: Tim Goudriaan --- src/DependencyInjection/Compiler/ParametersPass.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/DependencyInjection/Compiler/ParametersPass.php b/src/DependencyInjection/Compiler/ParametersPass.php index 0e9879ba..97f3983c 100644 --- a/src/DependencyInjection/Compiler/ParametersPass.php +++ b/src/DependencyInjection/Compiler/ParametersPass.php @@ -9,15 +9,19 @@ class ParametersPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { + $this->setMfaIssuer($container); $this->setTwigGlobal($container); } - private function setTwigGlobal(ContainerBuilder $container): void + private function setMfaIssuer(ContainerBuilder $container): void { - $parameterBag = $container->getParameterBag(); + $container->setParameter('scheb_two_factor.totp.issuer', $container->getParameter('dirigent.title')); + } + private function setTwigGlobal(ContainerBuilder $container): void + { $variables = [ - 'slug' => $parameterBag->get('dirigent.slug'), + 'slug' => $container->getParameter('dirigent.slug'), ]; $container->getDefinition('twig')->addMethodCall('addGlobal', ['dirigent', $variables]); From 517423a9f8e2824cfee7327ff50f2215c6f7570a Mon Sep 17 00:00:00 2001 From: Tim Goudriaan Date: Wed, 15 Oct 2025 16:12:15 +0200 Subject: [PATCH 10/10] Add option for administrators to disable MFA for users Signed-off-by: Tim Goudriaan --- .../Dashboard/DashboardUserController.php | 4 +++ src/Doctrine/Entity/User.php | 9 ++++++ .../MfaAuthenticationConfigurator.php | 29 +++++++++++++++++++ translations/messages.en.yaml | 5 ++++ 4 files changed, 47 insertions(+) create mode 100644 src/EasyAdmin/MfaAuthenticationConfigurator.php diff --git a/src/Controller/Dashboard/DashboardUserController.php b/src/Controller/Dashboard/DashboardUserController.php index 4047ba8d..b479a952 100644 --- a/src/Controller/Dashboard/DashboardUserController.php +++ b/src/Controller/Dashboard/DashboardUserController.php @@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; @@ -68,5 +69,8 @@ public function configureFields(string $pageName): iterable ->renderExpanded() ->allowMultipleChoices() ->setSortable(false); + yield BooleanField::new('totpAuthenticationEnabled', 'Multi-factor authentication') + ->setHelp('form.user.help.totp-authentication-enabled') + ->onlyOnForms(); } } diff --git a/src/Doctrine/Entity/User.php b/src/Doctrine/Entity/User.php index 286b090a..1207e694 100644 --- a/src/Doctrine/Entity/User.php +++ b/src/Doctrine/Entity/User.php @@ -182,6 +182,15 @@ public function isTotpAuthenticationEnabled(): bool return null !== $this->totpSecret; } + public function setTotpAuthenticationEnabled(bool $enabled): void + { + if (!$this->isTotpAuthenticationEnabled() || $enabled) { + throw new \LogicException(sprintf('TOTP authentication can not be enabled through the `%s` method.', __METHOD__)); + } + + $this->totpSecret = null; + } + public function getTotpAuthenticationUsername(): string { return $this->username; diff --git a/src/EasyAdmin/MfaAuthenticationConfigurator.php b/src/EasyAdmin/MfaAuthenticationConfigurator.php new file mode 100644 index 00000000..d64b49c4 --- /dev/null +++ b/src/EasyAdmin/MfaAuthenticationConfigurator.php @@ -0,0 +1,29 @@ +getFqcn() && 'totpAuthenticationEnabled' === $field->getProperty(); + } + + public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void + { + /** @var User $user */ + $user = $entityDto->getInstance(); + + if (!$user->isTotpAuthenticationEnabled()) { + $field->setFormTypeOption('disabled', true); + } + } +} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 4ccdcabf..486411e8 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -122,3 +122,8 @@ account: qr-code-alt: QR-code containing the MFA secret. state-disabled: Multi-factor authentication is currently disabled. state-enabled: Multi-factor authentication is currently enabled. + +form: + user: + help: + totp-authentication-enabled: Multi-factor authentication can be disabled for users that lost their access to their MFA code, but has to be enabled by the user.