diff --git a/.github/workflows/split.yaml b/.github/workflows/split.yaml index 1cb46959..46bd71dc 100644 --- a/.github/workflows/split.yaml +++ b/.github/workflows/split.yaml @@ -81,6 +81,10 @@ jobs: local_path: 'Rozier' split_repository: 'rozier' default_branch: 'main' + - + local_path: 'RoadizTwoFactorBundle' + split_repository: 'two-factor-bundle' + default_branch: 'main' steps: diff --git a/Makefile b/Makefile index 955a6914..d8c42ea2 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ test: php -d "memory_limit=-1" vendor/bin/phpcbf -p ./lib/RoadizFontBundle/src php -d "memory_limit=-1" vendor/bin/phpcbf -p ./lib/RoadizRozierBundle/src php -d "memory_limit=-1" vendor/bin/phpcbf -p ./lib/RoadizUserBundle/src + php -d "memory_limit=-1" vendor/bin/phpcbf -p ./lib/RoadizTwoFactorBundle/src php -d "memory_limit=-1" vendor/bin/phpcbf -p ./lib/Rozier/src php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon php -d "memory_limit=-1" bin/console lint:twig ./lib/Rozier/src/Resources/views @@ -27,6 +28,7 @@ test: php -d "memory_limit=-1" bin/console lint:twig ./lib/RoadizRozierBundle/templates php -d "memory_limit=-1" bin/console lint:twig ./lib/RoadizFontBundle/templates php -d "memory_limit=-1" bin/console lint:twig ./lib/RoadizCoreBundle/templates + php -d "memory_limit=-1" bin/console lint:twig ./lib/RoadizTwoFactorBundle/templates requirements: vendor/bin/requirements-checker diff --git a/composer.json b/composer.json index 1fb9e389..2c164ce3 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "doctrine/doctrine-migrations-bundle": "^3.1", "doctrine/migrations": "^3.1.1", "doctrine/orm": "^2.14.1", + "endroid/qr-code": "^4.0", "enshrined/svg-sanitize": "^0.15", "gedmo/doctrine-extensions": "~3.10.0", "guzzlehttp/guzzle": "^7.2.0", @@ -78,9 +79,15 @@ "roadiz/openid": "^2.1", "roadiz/random": "^2.1", "roadiz/rozier-bundle": "^2.1", + "roadiz/two-factor-bundle": "^2.1", "roadiz/user-bundle": "^2.1", "rollerworks/password-common-list": "^0.2.0", "rollerworks/password-strength-bundle": "^2.2", + "scheb/2fa-backup-code": "^6.8", + "scheb/2fa-bundle": "^6.8", + "scheb/2fa-google-authenticator": "^6.8", + "scheb/2fa-totp": "^6.8", + "scheb/2fa-trusted-device": "^6.8", "scienta/doctrine-json-functions": "^4.2", "sensio/framework-extra-bundle": "^6.1", "sentry/sentry-symfony": "^4.2", @@ -156,7 +163,7 @@ "symfony/browser-kit": "5.4.*", "symfony/css-selector": "5.4.*", "symfony/debug-bundle": "5.4.*", - "symfony/maker-bundle": "^1.0", + "symfony/maker-bundle": "^1.48", "symfony/phpunit-bridge": "5.4.*", "symfony/web-profiler-bundle": "5.4.*", "symplify/monorepo-builder": "11.2.2.72" @@ -195,6 +202,7 @@ "RZ\\Roadiz\\OpenId\\": "lib/OpenId/src/", "RZ\\Roadiz\\Random\\": "lib/Random/src/", "RZ\\Roadiz\\RozierBundle\\": "lib/RoadizRozierBundle/src/", + "RZ\\Roadiz\\TwoFactorBundle\\": "lib/RoadizTwoFactorBundle/src/", "RZ\\Roadiz\\Typescript\\Declaration\\": "lib/DtsGenerator/src/", "RZ\\Roadiz\\UserBundle\\": "lib/RoadizUserBundle/src/", "RZ\\Roadiz\\Utils\\": "lib/Models/src/Roadiz/Utils/", @@ -224,6 +232,7 @@ "roadiz/roadiz": "*", "roadiz/rozier": "*", "roadiz/rozier-bundle": "*", + "roadiz/two-factor-bundle": "*", "roadiz/user-bundle": "*" }, "scripts": { diff --git a/config/bundles.php b/config/bundles.php index 04eba879..4551dcf6 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,6 +17,7 @@ RZ\Roadiz\CompatBundle\RoadizCompatBundle::class => ['all' => true], RZ\Roadiz\RozierBundle\RoadizRozierBundle::class => ['all' => true], RZ\Roadiz\UserBundle\RoadizUserBundle::class => ['all' => true], + RZ\Roadiz\TwoFactorBundle\RoadizTwoFactorBundle::class => ['all' => true], JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true], Symfony\Cmf\Bundle\RoutingBundle\CmfRoutingBundle::class => ['all' => true], Rollerworks\Bundle\PasswordStrengthBundle\RollerworksPasswordStrengthBundle::class => ['all' => true], @@ -28,4 +29,5 @@ Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Rollerworks\Bundle\PasswordCommonListBundle\RollerworksPasswordCommonListBundle::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 00000000..5d47ae5d --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,26 @@ +# 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 + + google: + enabled: true + server_name: "%env(string:APP_NAMESPACE)%" + issuer: "%env(string:APP_TITLE)%" + totp: + enabled: true # If TOTP authentication should be enabled, default false + server_name: "%env(string:APP_NAMESPACE)%" + issuer: "%env(string:APP_TITLE)%" + + # Trusted device feature + trusted_device: + enabled: true # If the trusted device feature should be enabled + lifetime: 5184000 # Lifetime of the trusted device cookie + extend_lifetime: true + key: "%env(string:APP_SECRET)%" + cookie_name: trusted_device # Name of the trusted device cookie + cookie_secure: false # Set the 'Secure' (HTTPS Only) flag on the trusted device cookie + cookie_same_site: "lax" # The same-site option of the cookie, can be "lax" or "strict" + backup_codes: + enabled: true # If the backup code feature should be enabled diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a7cfdd61..3c315663 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -48,6 +48,9 @@ security: main: lazy: true provider: all_users + two_factor: + auth_form_path: 2fa_login # The route name you have used in the routes.yaml + check_path: 2fa_login_check # The route name you have used in the routes.yaml # activate different ways to authenticate # https://symfony.com/doc/current/security.html#firewalls-authentication @@ -73,14 +76,17 @@ security: # 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: ^/rz-admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/rz-admin/login, roles: PUBLIC_ACCESS } + - { path: ^/rz-admin/logout, roles: PUBLIC_ACCESS } - { path: ^/rz-admin, roles: ROLE_BACKEND_USER } - - { path: ^/api/token, roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/custom_forms/(?:[0-9]+)/post", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/contact_form/post", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/users/signup", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/users/password_request", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/users/password_reset", methods: [ PUT ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + # This ensures that the form can only be accessed when two-factor authentication is in progress. + - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/api/token, roles: PUBLIC_ACCESS } + - { path: "^/api/custom_forms/(?:[0-9]+)/post", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/contact_form/post", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/users/signup", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/users/password_request", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/users/password_reset", methods: [ PUT ], roles: PUBLIC_ACCESS } - { path: "^/api/users", methods: [ GET, PUT, PATCH, POST ], roles: ROLE_USER } - { path: ^/api, roles: ROLE_BACKEND_USER, methods: [ POST, PUT, PATCH, DELETE ] } # - { path: ^/profile, roles: ROLE_USER } diff --git a/config/routes.yaml b/config/routes.yaml index c7e2a71e..b274f9b6 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -11,6 +11,9 @@ roadiz_rozier: roadiz_font: resource: "@RoadizFontBundle/config/routing.yaml" +roadiz_two_factor: + resource: "@RoadizTwoFactorBundle/config/routing.yaml" + rz_intervention_request: resource: "@RZInterventionRequestBundle/Resources/config/routing.yml" prefix: / 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/lib/RoadizCoreBundle/config/packages/security.yaml b/lib/RoadizCoreBundle/config/packages/security.yaml index 00ef8080..c8123525 100644 --- a/lib/RoadizCoreBundle/config/packages/security.yaml +++ b/lib/RoadizCoreBundle/config/packages/security.yaml @@ -70,9 +70,10 @@ security: # 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: ^/rz-admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/rz-admin/login, roles: PUBLIC_ACCESS } + - { path: ^/rz-admin/logout, roles: PUBLIC_ACCESS } - { path: ^/rz-admin, roles: ROLE_BACKEND_USER } - - { path: ^/api/token, roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/custom_forms/(?:[0-9]+)/post", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/api/token, roles: PUBLIC_ACCESS } + - { path: "^/api/custom_forms/(?:[0-9]+)/post", methods: [ POST ], roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_BACKEND_USER, methods: [ POST, PUT, PATCH, DELETE ] } # - { path: ^/profile, roles: ROLE_USER } diff --git a/lib/RoadizRozierBundle/README.md b/lib/RoadizRozierBundle/README.md index 0e7f4388..00ab4160 100644 --- a/lib/RoadizRozierBundle/README.md +++ b/lib/RoadizRozierBundle/README.md @@ -81,7 +81,7 @@ security: custom_authenticator: - RZ\Roadiz\RozierBundle\Security\RozierAuthenticator access_control: - - { path: ^/rz-admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/rz-admin/login, roles: PUBLIC_ACCESS } - { path: ^/rz-admin, roles: ROLE_BACKEND_USER } ``` - Add custom routes: diff --git a/lib/RoadizTwoFactorBundle/README.md b/lib/RoadizTwoFactorBundle/README.md new file mode 100644 index 00000000..c55baefc --- /dev/null +++ b/lib/RoadizTwoFactorBundle/README.md @@ -0,0 +1,58 @@ +# Roadiz Font bundle + +![Run test status](https://github.com/roadiz/two-factor-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/two-factor-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/two-factor-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\TwoFactor\RoadizTwoFactor::class => ['all' => true], +]; +``` + +## Configuration + +- Copy and merge `@RoadizTwoFactor/config/packages/*` files into your project `config/packages` folder +```yaml +# config/routes.yaml +roadiz_two_bundle: + resource: "@RoadizTwoFactor/config/routing.yaml" +``` + +## Contributing + +Report [issues](https://github.com/roadiz/core-bundle-dev-app/issues) and send [Pull Requests](https://github.com/roadiz/core-bundle-dev-app/pulls) in the [main Roadiz repository](https://github.com/roadiz/core-bundle-dev-app) diff --git a/lib/RoadizTwoFactorBundle/composer.json b/lib/RoadizTwoFactorBundle/composer.json new file mode 100644 index 00000000..e5af6a11 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/composer.json @@ -0,0 +1,69 @@ +{ + "name": "roadiz/two-factor-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "require": { + "php": ">=8.0", + "doctrine/orm": "^2.14.1", + "endroid/qr-code": "^4.0", + "roadiz/core-bundle": "^2.1", + "roadiz/rozier-bundle": "^2.1", + "scheb/2fa-backup-code": "^6.8", + "scheb/2fa-bundle": "^6.8", + "scheb/2fa-totp": "^6.8", + "scheb/2fa-google-authenticator": "^6.8", + "scheb/2fa-trusted-device": "^6.8", + "sensio/framework-extra-bundle": "^6.1", + "symfony/framework-bundle": "5.4.*" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-doctrine": "^1.3", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5", + "symfony/stopwatch": "5.4.*" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\TwoFactorBundle\\": "src/" + } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.1.x-dev", + "dev-develop": "2.2.x-dev" + } + } +} diff --git a/lib/RoadizTwoFactorBundle/config/routing.yaml b/lib/RoadizTwoFactorBundle/config/routing.yaml new file mode 100644 index 00000000..2735c7cb --- /dev/null +++ b/lib/RoadizTwoFactorBundle/config/routing.yaml @@ -0,0 +1,11 @@ + +2fa_admin_two_factor: + path: /rz-admin/two-factor + defaults: + _controller: RZ\Roadiz\TwoFactorBundle\Controller\TwoFactorAdminController::twoFactorAdminAction + + +2fa_qr_code_totp: + path: /rz-admin/two-factor/qr/totp + defaults: + _controller: RZ\Roadiz\TwoFactorBundle\Controller\QrCodeController::totpQrCodeAction diff --git a/lib/RoadizTwoFactorBundle/config/services.yaml b/lib/RoadizTwoFactorBundle/config/services.yaml new file mode 100644 index 00000000..b8711534 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/config/services.yaml @@ -0,0 +1,26 @@ +--- +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: {} + + RZ\Roadiz\TwoFactorBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Traits/' + - '../src/Kernel.php' + - '../src/Tests/' + - '../src/Event/' + + RZ\Roadiz\TwoFactorBundle\Controller\: + resource: '../src/Controller' + tags: [ 'controller.service_arguments' ] + + roadiz_two_factor.security.totp.provider: + class: RZ\Roadiz\TwoFactorBundle\Security\Provider\AuthenticatorTwoFactorProvider + tags: + - { name: scheb_two_factor.provider, alias: 'roadiz_totp' } diff --git a/lib/RoadizTwoFactorBundle/migrations/Version20230413154052.php b/lib/RoadizTwoFactorBundle/migrations/Version20230413154052.php new file mode 100644 index 00000000..00babf47 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/migrations/Version20230413154052.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE two_factor_users (user_id INT NOT NULL, secret VARCHAR(255) DEFAULT NULL, backup_codes JSON DEFAULT NULL, trusted_version INT DEFAULT 1 NOT NULL, algorithm VARCHAR(6) DEFAULT NULL, period SMALLINT DEFAULT NULL, digits SMALLINT DEFAULT NULL, UNIQUE INDEX UNIQ_12ED8E9FA76ED395 (user_id), PRIMARY KEY(user_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE two_factor_users ADD CONSTRAINT FK_12ED8E9FA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE untitled_folder'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE untitled_folder (untitled_id INT NOT NULL, folder_id INT NOT NULL, INDEX IDX_257063F7162CB942 (folder_id), INDEX IDX_257063F752B0ED85 (untitled_id), PRIMARY KEY(untitled_id, folder_id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE untitled_folder ADD CONSTRAINT FK_257063F7162CB942 FOREIGN KEY (folder_id) REFERENCES folders (id) ON UPDATE NO ACTION ON DELETE CASCADE'); + $this->addSql('ALTER TABLE untitled_folder ADD CONSTRAINT FK_257063F752B0ED85 FOREIGN KEY (untitled_id) REFERENCES nodes_sources (id) ON UPDATE NO ACTION ON DELETE CASCADE'); + $this->addSql('DROP TABLE two_factor_users'); + } +} diff --git a/lib/RoadizTwoFactorBundle/phpcs.xml.dist b/lib/RoadizTwoFactorBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizTwoFactorBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizTwoFactorBundle/phpstan.neon b/lib/RoadizTwoFactorBundle/phpstan.neon new file mode 100644 index 00000000..80dda276 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/phpstan.neon @@ -0,0 +1,32 @@ +parameters: + level: 5 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon diff --git a/lib/RoadizTwoFactorBundle/src/Controller/QrCodeController.php b/lib/RoadizTwoFactorBundle/src/Controller/QrCodeController.php new file mode 100644 index 00000000..bc30e2ce --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Controller/QrCodeController.php @@ -0,0 +1,64 @@ +denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + $user = $tokenStorage->getToken()->getUser(); + if (!($user instanceof User)) { + throw $this->createAccessDeniedException('You must be logged in to access this page.'); + } + $twoFactorUser = $this->twoFactorUserProvider->getFromUser($user); + + if (!($twoFactorUser instanceof TwoFactorInterface)) { + throw $this->createNotFoundException('Cannot display QR code'); + } + + if ($user instanceof GoogleAuthenticatorTwoFactorInterface) { + $qrCodeContent = $this->googleAuthenticator->getQrContent($twoFactorUser); + } else { + $qrCodeContent = $this->totpAuthenticator->getQrContent($twoFactorUser); + } + + $result = Builder::create() + ->writer(new PngWriter()) + ->writerOptions([]) + ->data($qrCodeContent) + ->encoding(new Encoding('UTF-8')) + ->errorCorrectionLevel(new ErrorCorrectionLevelHigh()) + ->size(200) + ->margin(0) + ->roundBlockSizeMode(new RoundBlockSizeModeMargin()) + ->build(); + + return new Response($result->getString(), 200, ['Content-Type' => 'image/png']); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Controller/TwoFactorAdminController.php b/lib/RoadizTwoFactorBundle/src/Controller/TwoFactorAdminController.php new file mode 100644 index 00000000..71ea56c9 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Controller/TwoFactorAdminController.php @@ -0,0 +1,60 @@ +denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + $user = $tokenStorage->getToken()->getUser(); + if (!($user instanceof User)) { + throw $this->createAccessDeniedException('You must be logged in to access this page.'); + } + $twoFactorUser = $this->managerRegistry + ->getRepository(TwoFactorUser::class) + ->findOneBy(['user' => $user]); + + if (!$twoFactorUser instanceof TwoFactorUser) { + $twoFactorUser = new TwoFactorUser(); + $twoFactorUser->setUser($user); + + $form = $this->createForm(FormType::class, $twoFactorUser); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $twoFactorUser->setSecret(base64_encode((new TokenGenerator())->generateToken())); + $this->managerRegistry->getManager()->persist($twoFactorUser); + $this->managerRegistry->getManager()->flush(); + return $this->redirectToRoute('2fa_admin_two_factor'); + } + $this->assignation['form'] = $form->createView(); + } + + $this->assignation['displayQrCodeTotp'] = $twoFactorUser instanceof TwoFactorInterface && $twoFactorUser->isTotpAuthenticationEnabled(); + + return $this->render('@RoadizTwoFactor/admin/user.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php b/lib/RoadizTwoFactorBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php new file mode 100644 index 00000000..4ad9f689 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php @@ -0,0 +1,59 @@ +hasDefinition('doctrine.migrations.configuration')) { + $configurationDefinition = $container->getDefinition('doctrine.migrations.configuration'); + $ns = 'RZ\Roadiz\TwoFactorBundle\Migrations'; + $path = '@RoadizTwoFactorBundle/migrations'; + + $path = $this->checkIfBundleRelativePath($path, $container); + $configurationDefinition->addMethodCall('addMigrationsDirectory', [$ns, $path]); + } + } + + private function checkIfBundleRelativePath(string $path, ContainerBuilder $container): string + { + if (isset($path[0]) && $path[0] === '@') { + $pathParts = explode('/', $path); + $bundleName = substr($pathParts[0], 1); + + $bundlePath = $this->getBundlePath($bundleName, $container); + + return $bundlePath . substr($path, strlen('@' . $bundleName)); + } + + return $path; + } + + private function getBundlePath(string $bundleName, ContainerBuilder $container): string + { + $bundleMetadata = $container->getParameter('kernel.bundles_metadata'); + assert(is_array($bundleMetadata)); + + if (! isset($bundleMetadata[$bundleName])) { + throw new RuntimeException( + sprintf( + 'The bundle "%s" has not been registered, available bundles: %s', + $bundleName, + implode(', ', array_keys($bundleMetadata)) + ) + ); + } + + return $bundleMetadata[$bundleName]['path']; + } +} diff --git a/lib/RoadizTwoFactorBundle/src/DependencyInjection/RoadizTwoFactorExtension.php b/lib/RoadizTwoFactorBundle/src/DependencyInjection/RoadizTwoFactorExtension.php new file mode 100644 index 00000000..dfc73ba5 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/DependencyInjection/RoadizTwoFactorExtension.php @@ -0,0 +1,28 @@ +load('services.yaml'); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Entity/TwoFactorUser.php b/lib/RoadizTwoFactorBundle/src/Entity/TwoFactorUser.php new file mode 100644 index 00000000..d07c3be0 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Entity/TwoFactorUser.php @@ -0,0 +1,199 @@ + 1])] + private int $trustedVersion = 1; + + #[ORM\Column(type: 'string', length: 6, nullable: true)] + private ?string $algorithm = TotpConfiguration::ALGORITHM_SHA1; + + #[ORM\Column(type: 'smallint', nullable: true)] + private ?int $period = 30; + + #[ORM\Column(type: 'smallint', nullable: true)] + private ?int $digits = 6; + + /** + * @return User|null + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param User|null $user + * @return TwoFactorUser + */ + public function setUser(?User $user): TwoFactorUser + { + $this->user = $user; + return $this; + } + + /** + * @param string|null $secret + * @return TwoFactorUser + */ + public function setSecret(?string $secret): TwoFactorUser + { + $this->secret = $secret; + return $this; + } + + /** + * @return string + */ + public function getAlgorithm(): string + { + return $this->algorithm ?? TotpConfiguration::ALGORITHM_SHA1; + } + + /** + * @param string|null $algorithm + * @return TwoFactorUser + */ + public function setAlgorithm(?string $algorithm): TwoFactorUser + { + $this->algorithm = $algorithm; + return $this; + } + + /** + * @return int + */ + public function getPeriod(): int + { + return $this->period ?? 30; + } + + /** + * @param int|null $period + * @return TwoFactorUser + */ + public function setPeriod(?int $period): TwoFactorUser + { + $this->period = $period; + return $this; + } + + /** + * @return int + */ + public function getDigits(): int + { + return $this->digits ?? 6; + } + + /** + * @param int|null $digits + * @return TwoFactorUser + */ + public function setDigits(?int $digits): TwoFactorUser + { + $this->digits = $digits; + return $this; + } + + public function isTotpAuthenticationEnabled(): bool + { + return (bool) $this->secret; + } + + public function getTotpAuthenticationUsername(): string + { + if ($this->user === null) { + throw new \RuntimeException('User cannot be null'); + } + return $this->user->getUserIdentifier(); + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + // You could persist the other configuration options in the user entity to make it individual per user. + return new TotpConfiguration($this->secret, $this->getAlgorithm(), $this->getPeriod(), $this->getDigits()); + } + + public function isGoogleAuthenticatorEnabled(): bool + { + return (bool) $this->secret; + } + + public function getGoogleAuthenticatorUsername(): string + { + if ($this->user === null) { + throw new \RuntimeException('User cannot be null'); + } + return $this->user->getUserIdentifier(); + } + + public function getGoogleAuthenticatorSecret(): ?string + { + return $this->secret; + } + + /** + * Check if it is a valid backup code. + */ + public function isBackupCode(string $code): bool + { + return in_array($code, $this->backupCodes); + } + + /** + * Invalidate a backup code + */ + public function invalidateBackupCode(string $code): void + { + $key = array_search($code, $this->backupCodes); + if ($key !== false) { + unset($this->backupCodes[$key]); + } + } + + /** + * Add a backup code + */ + public function addBackUpCode(string $backUpCode): void + { + if (!in_array($backUpCode, $this->backupCodes)) { + $this->backupCodes[] = $backUpCode; + } + } + + public function getTrustedTokenVersion(): int + { + return $this->trustedVersion; + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Repository/TwoFactorUserRepository.php b/lib/RoadizTwoFactorBundle/src/Repository/TwoFactorUserRepository.php new file mode 100644 index 00000000..2a6542ed --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Repository/TwoFactorUserRepository.php @@ -0,0 +1,20 @@ +addCompilerPass(new DoctrineMigrationCompilerPass()); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Security/Provider/AuthenticatorTwoFactorProvider.php b/lib/RoadizTwoFactorBundle/src/Security/Provider/AuthenticatorTwoFactorProvider.php new file mode 100644 index 00000000..8bd9c808 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Security/Provider/AuthenticatorTwoFactorProvider.php @@ -0,0 +1,81 @@ +getUser(); + if (!($user instanceof User)) { + return false; + } + + $twoFactorUser = $this->getTwoFactorFromUser($user); + + if (!($twoFactorUser instanceof TwoFactorInterface && $twoFactorUser->isTotpAuthenticationEnabled())) { + return false; + } + + $totpConfiguration = $twoFactorUser->getTotpAuthenticationConfiguration(); + if (null === $totpConfiguration) { + throw new TwoFactorProviderLogicException( + 'User has to provide a TotpAuthenticationConfiguration for TOTP authentication.' + ); + } + + $secret = $totpConfiguration->getSecret(); + if (0 === strlen($secret)) { + throw new TwoFactorProviderLogicException( + 'User has to provide a secret code for TOTP authentication.' + ); + } + + return true; + } + + public function prepareAuthentication(object $user): void + { + } + + public function validateAuthenticationCode(object $user, string $authenticationCode): bool + { + if ($user instanceof User) { + $user = $this->getTwoFactorFromUser($user); + } + + if (!($user instanceof TwoFactorInterface)) { + return false; + } + + return $this->authenticator->checkCode($user, $authenticationCode); + } + + public function getFormRenderer(): TwoFactorFormRendererInterface + { + return $this->formRenderer; + } + + private function getTwoFactorFromUser(User $user): ?TwoFactorUser + { + return $this->twoFactorUserProvider->getFromUser($user); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProvider.php b/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProvider.php new file mode 100644 index 00000000..b46e3064 --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProvider.php @@ -0,0 +1,24 @@ +managerRegistry + ->getRepository(TwoFactorUser::class) + ->findOneBy(['user' => $user]); + } +} diff --git a/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProviderInterface.php b/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProviderInterface.php new file mode 100644 index 00000000..4577958c --- /dev/null +++ b/lib/RoadizTwoFactorBundle/src/Security/Provider/TwoFactorUserProviderInterface.php @@ -0,0 +1,13 @@ + +{%- endblock -%} + +{%- block content_body -%} +
+ {% if form %} + {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form) }} + {{ form_widget(form) }} +
+ {% trans %}are_you_sure.activate.two_factor{% endtrans %} + {% apply spaceless %} + {% trans %}cancel{% endtrans %} + + {% endapply %} +
+ {{ form_end(form) }} + {% endif %} + {% if displayQrCodeTotp %} +

{% trans %}scan_qr_code_with_totp_app{% endtrans %}

+

Two factor QRCode

+ {% endif %} +
+{%- endblock -%} diff --git a/lib/RoadizUserBundle/README.md b/lib/RoadizUserBundle/README.md index e05a5d44..00dfb5c2 100644 --- a/lib/RoadizUserBundle/README.md +++ b/lib/RoadizUserBundle/README.md @@ -82,9 +82,9 @@ framework: security: access_control: # Append user routes configuration - - { path: "^/api/users/signup", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/users/password_request", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "^/api/users/password_reset", methods: [ PUT ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/api/users/signup", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/users/password_request", methods: [ POST ], roles: PUBLIC_ACCESS } + - { path: "^/api/users/password_reset", methods: [ PUT ], roles: PUBLIC_ACCESS } - { path: "^/api/users", methods: [ GET, PUT, PATCH, POST ], roles: ROLE_USER } ``` - Edit your `./.env` file with: diff --git a/phpstan.neon b/phpstan.neon index 8391eb7b..a3cabb0a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,7 @@ parameters: - lib/RoadizFontBundle/src - lib/RoadizRozierBundle/src - lib/RoadizUserBundle/src + - lib/RoadizTwoFactorBundle/src - lib/Rozier/src - src bootstrapFiles: