diff --git a/composer.json b/composer.json index 2c5951c25..8a7e09274 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,10 @@ "psr/container": "2.*", "psr/log": "3.0.2 as 1.1.4", "ramsey/uuid-doctrine": "2.*", + "scheb/2fa-backup-code": "^7.11.0", + "scheb/2fa-bundle": "^7.11.0", + "scheb/2fa-google-authenticator": "^7.11.0", + "scheb/2fa-totp": "^7.11.0", "stof/doctrine-extensions-bundle": "^1.7", "symfony/apache-pack": "^1.0", "symfony/asset": "7.3.*", diff --git a/composer.lock b/composer.lock index 5b3a358fd..d0fee9b9c 100644 --- a/composer.lock +++ b/composer.lock @@ -5636,6 +5636,73 @@ ], "time": "2024-12-13T15:12:11+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": "paragonie/random_compat", "version": "v9.99.100", @@ -6513,6 +6580,223 @@ ], "time": "2024-05-27T00:00:21+00:00" }, + { + "name": "scheb/2fa-backup-code", + "version": "v7.11.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-backup-code.git", + "reference": "62c6099b179903db5ab03b8059068cdb28659294" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/62c6099b179903db5ab03b8059068cdb28659294", + "reference": "62c6099b179903db5ab03b8059068cdb28659294", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "scheb/2fa-bundle": "self.version" + }, + "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 backup codes support", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "backup-codes", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-backup-code/tree/v7.11.0" + }, + "time": "2025-04-20T08:27:40+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": "scheb/2fa-google-authenticator", + "version": "v7.11.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-google-authenticator.git", + "reference": "01a446eb68a76c3d0528a190029afa5e6ce5c384" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/01a446eb68a76c3d0528a190029afa5e6ce5c384", + "reference": "01a446eb68a76c3d0528a190029afa5e6ce5c384", + "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 Google Authenticator", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "google-authenticator", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-google-authenticator/tree/v7.11.0" + }, + "time": "2025-04-20T08:38:44+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": "revolt/event-loop", "version": "v1.0.7", @@ -6898,6 +7182,88 @@ ], "time": "2025-08-13T04:44:59+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": "stof/doctrine-extensions-bundle", "version": "v1.14.0", diff --git a/config/bundles.php b/config/bundles.php index ca9528667..a5a52c7b3 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -24,4 +24,5 @@ Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true], BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], ]; diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 000000000..660352a15 --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,23 @@ +# config/packages/scheb_2fa.yaml +scheb_two_factor: + backup_codes: + enabled: true + totp: + enabled: true # If TOTP authentication should be enabled, default false + server_name: bewelcome.org # Server name used in QR code + issuer: BeWelcome # Issuer name used in QR code + leeway: 60 # Acceptable time drift in seconds, must be less or equal than the TOTP period + parameters: # Additional parameters added in the QR code + # image: 'https://my-service/img/logo.png' + image: 'https://miro.medium.com/v2/resize:fill:128:128/1*65AfOY_oNSTe2G1bFMwQ4A.jpeg' + template: security/2fa_form.html.twig + google: + enabled: true + server_name: bewelcome.org # Server name used in QR code + issuer: BeWelcome # Issuer name used in QR code + leeway: 60 # Acceptable time drift in seconds, must be less or equal than the TOTP period + template: security/2fa_form.html.twig + security_tokens: + # - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + # Symfony 7.2 default enable_authenticator_manager, per profiler _security_skipped_authenticators + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 61686aec0..1f405cf15 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -46,9 +46,14 @@ security: # https://symfony.com/doc/current/security/impersonating_user.html # switch_user: true + two_factor: + auth_form_path: 2fa_login + check_path: 2fa_login_check + # 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: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } - { path: ^/$, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/about, roles: PUBLIC_ACCESS } @@ -80,6 +85,7 @@ security: - { path: ^/members/avatar/, roles: PUBLIC_ACCESS } - { path: ^/deleteprofile, roles: PUBLIC_ACCESS } - { path: ^/password/check, roles: PUBLIC_ACCESS } + - { path: ^/logout, roles: PUBLIC_ACCESS } - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/api, roles: PUBLIC_ACCESS } - { path: ^/, roles: ROLE_USER } diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml new file mode 100644 index 000000000..a8a3d5e03 --- /dev/null +++ b/config/routes/scheb_2fa.yaml @@ -0,0 +1,9 @@ +# config/routes/scheb_2fa.yaml +2fa_login: + path: /2fa + # "scheb_two_factor.form_controller" references the controller service provided by the bundle. + # You don't HAVE to use it, but - except you have very special requirements - it is recommended. + controller: "scheb_two_factor.form_controller::form" + +2fa_login_check: + path: /2fa_check diff --git a/src/Entity/Member.php b/src/Entity/Member.php index 2924eb84d..d0dcd5b6c 100644 --- a/src/Entity/Member.php +++ b/src/Entity/Member.php @@ -21,6 +21,11 @@ use Doctrine\ORM\Event\PostLoadEventArgs; use Doctrine\ORM\Mapping as ORM; use Exception; +use Scheb\TwoFactorBundle\Model\BackupCodeInterface; +use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface as GoogleTwoFactorInterface; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; +use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface as TotpTwoFactorInterface; use Serializable; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherAwareInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -34,7 +39,7 @@ #[ORM\Table(name: 'members')] #[ORM\Entity(repositoryClass: MemberRepository::class)] #[ORM\HasLifecycleCallbacks] -class Member implements Serializable, UserInterface, PasswordHasherAwareInterface, PasswordAuthenticatedUserInterface +class Member implements Serializable, UserInterface, PasswordHasherAwareInterface, PasswordAuthenticatedUserInterface, GoogleTwoFactorInterface, TotpTwoFactorInterface, BackupCodeInterface { public const ROLE_ADMIN_ACCEPTER = 'ROLE_ADMIN_ACCEPTER'; public const ROLE_ADMIN_ADMIN = 'ROLE_ADMIN_ADMIN'; @@ -272,6 +277,21 @@ class Member implements Serializable, UserInterface, PasswordHasherAwareInterfac private ?Language $preferredLanguage = null; + /** + * @ORM\Column(type="json") + */ + private array $backupCodes = []; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private ?string $totpSecret; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private ?string $googleAuthenticatorSecret; + public function __construct() { $this->addresses = new ArrayCollection(); @@ -1556,4 +1576,72 @@ public function getTranslatedFieldsIndexed(): array return $fields; } + + /** + * Check if it is a valid backup code. + */ + public function isBackupCode(string $code): bool + { + return \in_array($code, $this->backupCodes, true); + } + + /** + * Invalidate a backup code. + */ + public function invalidateBackupCode(string $code): void + { + $key = array_search($code, $this->backupCodes, true); + if (false !== $key) { + unset($this->backupCodes[$key]); + } + } + + /** + * Add a backup code. + */ + public function addBackUpCode(string $backUpCode): void + { + if (!\in_array($backUpCode, $this->backupCodes, true)) { + $this->backupCodes[] = $backUpCode; + } + } + + public function isTotpAuthenticationEnabled(): bool + { + return $this->totpSecret ? true : false; + } + + public function getTotpAuthenticationUsername(): string + { + return $this->username; + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + // You could persist the other configuration options in the user entity to make it individual per user. + $period = 20; + $digits = 6; + + return null !== $this->totpSecret ? new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, $period, $digits) : null; + } + + public function isGoogleAuthenticatorEnabled(): bool + { + return null !== $this->googleAuthenticatorSecret; + } + + public function getGoogleAuthenticatorUsername(): string + { + return $this->username; + } + + public function getGoogleAuthenticatorSecret(): ?string + { + return $this->googleAuthenticatorSecret; + } + + public function setGoogleAuthenticatorSecret(?string $googleAuthenticatorSecret): void + { + $this->googleAuthenticatorSecret = $googleAuthenticatorSecret; + } } diff --git a/symfony.lock b/symfony.lock index f28a5b89b..380f1771b 100644 --- a/symfony.lock +++ b/symfony.lock @@ -441,6 +441,19 @@ "sanmai/pipeline": { "version": "v5.1.0" }, + "scheb/2fa-bundle": { + "version": "7.11", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "sebastian/cli-parser": { "version": "1.0.1" }, diff --git a/templates/security/2fa_form.html.twig b/templates/security/2fa_form.html.twig new file mode 100644 index 000000000..1bb7e54b4 --- /dev/null +++ b/templates/security/2fa_form.html.twig @@ -0,0 +1,39 @@ +{% extends "base.html.twig" %} + +{% block body %} +

Two-Factor Authentication

+ + {% if authenticationError %} +

{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}

+ {% endif %} + +
+

+ + + or Cancel 2FA +

+ + {% if twoFactorProvider == "email" %} +

Hint: The current authentication code is: {{ app.user.emailAuthCode }}

+ {% endif %} + + {% if displayTrustedOption %} +

+ {% endif %} + + {% if availableTwoFactorProviders|length > 1 %} +
+

Choose authentication method: + {% for provider in availableTwoFactorProviders %} + {{ provider }} + {% if not loop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} + + {% if isCsrfProtectionEnabled %} + + {% endif %} +
+{% endblock %}