From 5b4326761b021c8aa55192612fb4434f13693b18 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Thu, 4 May 2023 18:33:02 +0300 Subject: [PATCH 1/9] Implement Two Factor Authentication --- assets/js/bottom-bar.js | 10 +- assets/js/user/_sidebar.js | 4 +- .../user/two_factor/google_authenticator.js | 89 ++++ assets/styles/user/security.scss | 6 + composer.json | 4 + composer.lock | 494 +++++++++++++++++- config/bundles.php | 1 + config/packages/scheb_2fa.yaml | 16 + config/packages/security.yaml | 11 + config/routes/sched_2fa.yaml | 7 + migrations/Version20230501134351.php | 31 ++ public/build/css/security.64e38d75.css | 1 + public/build/entrypoints.json | 16 +- .../build/js/google_authenticator.0889962e.js | 1 + .../js/{user.051ef9ab.js => user.fa1131dc.js} | 2 +- public/build/manifest.json | 4 +- .../GoogleAuthenticatorController.php | 69 +++ .../{ => Security}/PasswordController.php | 2 +- .../Auth/ResetPasswordController.php | 4 +- .../TwoFactor/EnterAuthCodeController.php | 55 ++ src/Controller/BaseController.php | 16 +- src/Controller/Traits/MenuTrait.php | 29 + src/Controller/User/SecurityController.php | 21 + src/Entity/Traits/TwoFactorTrait.php | 34 ++ src/Entity/User.php | 6 +- src/EventSubscriber/ControllerSubscriber.php | 3 +- .../User/GoogleAuthenticatorAdapter.php | 37 ++ .../User/GoogleAuthenticatorService.php | 95 ++++ symfony.lock | 13 + .../auth/two_factor/two_factor_form.html.twig | 51 ++ .../common/_floating_action_button.html.twig | 13 + templates/user/common/_sidebar.html.twig | 42 +- templates/user/photo/edit.html.twig | 7 +- templates/user/profile/profile.html.twig | 20 +- templates/user/property/edit.html.twig | 7 +- templates/user/property/index.html.twig | 21 +- templates/user/property/new.html.twig | 7 +- .../_change_password.html.twig | 2 +- .../security/_google_authenticator.html.twig | 74 +++ templates/user/security/security.html.twig | 67 +++ tests/E2E/User/PasswordChangeTest.php | 1 + .../Controller/Ajax/CityControllerTest.php | 8 - .../GoogleAuthenticatorControllerTest.php | 131 +++++ .../{ => Security}/PasswordControllerTest.php | 4 +- tests/Helper/WebTestHelper.php | 8 + translations/messages.en.xlf | 65 +++ translations/messages.ru.xlf | 65 +++ webpack.config.js | 2 + 48 files changed, 1565 insertions(+), 111 deletions(-) create mode 100644 assets/js/user/two_factor/google_authenticator.js create mode 100644 assets/styles/user/security.scss create mode 100644 config/packages/scheb_2fa.yaml create mode 100644 config/routes/sched_2fa.yaml create mode 100644 migrations/Version20230501134351.php create mode 100644 public/build/css/security.64e38d75.css create mode 100644 public/build/js/google_authenticator.0889962e.js rename public/build/js/{user.051ef9ab.js => user.fa1131dc.js} (56%) create mode 100644 src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php rename src/Controller/Ajax/User/{ => Security}/PasswordController.php (94%) create mode 100644 src/Controller/Auth/TwoFactor/EnterAuthCodeController.php create mode 100644 src/Controller/Traits/MenuTrait.php create mode 100644 src/Controller/User/SecurityController.php create mode 100644 src/Entity/Traits/TwoFactorTrait.php create mode 100644 src/Service/User/GoogleAuthenticatorAdapter.php create mode 100644 src/Service/User/GoogleAuthenticatorService.php create mode 100644 templates/auth/two_factor/two_factor_form.html.twig create mode 100644 templates/user/common/_floating_action_button.html.twig rename templates/user/{common => security}/_change_password.html.twig (98%) create mode 100644 templates/user/security/_google_authenticator.html.twig create mode 100644 templates/user/security/security.html.twig create mode 100644 tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php rename tests/Functional/Controller/User/{ => Security}/PasswordControllerTest.php (94%) diff --git a/assets/js/bottom-bar.js b/assets/js/bottom-bar.js index 1d311c29..57047e30 100644 --- a/assets/js/bottom-bar.js +++ b/assets/js/bottom-bar.js @@ -1,13 +1,15 @@ (function ($) { - 'use strict'; const $phone = $('#phone'); const $email = $('#email'); $('body').append(`
- ${$phone.attr('title')} - ${$email.attr('title')} + ${$phone.attr('title')} + ${$email.attr('title')}
`); - })(window.jQuery); diff --git a/assets/js/user/_sidebar.js b/assets/js/user/_sidebar.js index ce573b52..6e1f36c9 100644 --- a/assets/js/user/_sidebar.js +++ b/assets/js/user/_sidebar.js @@ -7,7 +7,9 @@ $('.list-group-item-action:eq(2)').addClass('active'); } else if (currentUrl.indexOf('unpublished') !== -1) { $('.list-group-item-action:eq(1)').addClass('active'); + } else if (currentUrl.indexOf('security') !== -1) { + $('.list-group-item-action:eq(3)').addClass('active'); } else { $('.list-group-item-action:eq(0)').addClass('active'); } -})($); +})(window.jQuery); diff --git a/assets/js/user/two_factor/google_authenticator.js b/assets/js/user/two_factor/google_authenticator.js new file mode 100644 index 00000000..3d03a2e1 --- /dev/null +++ b/assets/js/user/two_factor/google_authenticator.js @@ -0,0 +1,89 @@ +(function ($) { + 'use strict'; + + const $form = $('#generate_google_auth_secret'); + const token = $('[name="auth_token"]').val(); + const $secret = $('[name="generatedSecret"]'); + const $authentication_code = $('[name="authentication_code"]'); + + // Open modal window + $('#setUpAuthenticatorButton').click(function () { + if ($form.data('generate-new-secret') === true) { + $.ajax({ + method: 'GET', + url: '/en/user/google_authenticator_code', + data: { csrf_token: token } + }).done(function (response) { + const { secret, qr_code } = response; + const image = new Image(); + image.src = qr_code; + + $secret.val(secret); + $('#generatedQrCode').html(image); + $('#generatedSecret').html(secret); + }); + } + }); + + // Enable 2fa + $('#enable2fa').click(function () { + let authentication_code = $authentication_code.val().trim(); + + if (!authentication_code) { + $authentication_code.addClass('is-invalid').focus(); + + return; + } + + $.ajax({ + method: 'PUT', + url: $form.attr('action'), + data: { + csrf_token: token, + secret: $secret.val(), + authentication_code: authentication_code + } + }) + .done(function () { + location.reload(); + }) + .fail(function (data) { + showError(data.responseJSON.message); + }); + }); + + // Disable 2fa + $('#disable2fa').click(function () { + $.ajax({ + method: 'DELETE', + url: $form.attr('action'), + data: { csrf_token: token } + }) + .done(function () { + location.reload(); + }) + .fail(function () { + location.reload(); + }); + }); + + $authentication_code.keyup(function () { + $(this).removeClass('is-invalid'); + }); + + // Reset form state + $('#setUpAuthenticator').on('hidden.bs.modal', function () { + resetFormState(); + }); + + function resetFormState() { + $authentication_code.val('').removeClass('is-invalid'); + $('#twoFactorAuthErrorMessage').text('').addClass('d-none'); + } + + function showError(message) { + $('#twoFactorAuthErrorMessage') + .text(message) + .removeClass('d-none'); + } +})(window.jQuery); diff --git a/assets/styles/user/security.scss b/assets/styles/user/security.scss new file mode 100644 index 00000000..09853db5 --- /dev/null +++ b/assets/styles/user/security.scss @@ -0,0 +1,6 @@ +.security-buttons a i { + background-color: #fff; + padding: 20px; + border-radius: 50%; + color: #BDC3C7; +} diff --git a/composer.json b/composer.json index 83852693..3424a21f 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,12 @@ "doctrine/doctrine-bundle": "^2.8", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.13", + "endroid/qr-code": "^4.8", "gregwar/image": "master", "knplabs/knp-paginator-bundle": "^5.6", "phpdocumentor/reflection-docblock": "^5.2", + "scheb/2fa-bundle": "^6.8", + "scheb/2fa-google-authenticator": "^6.8", "symfony/asset": "^6.2", "symfony/cache": "^6.2", "symfony/console": "^6.2", @@ -59,6 +62,7 @@ "dbrekelmans/bdi": "^1.0", "doctrine/doctrine-fixtures-bundle": "^3.4", "friendsofphp/php-cs-fixer": "^3.2", + "phpgangsta/googleauthenticator": "dev-master", "phpunit/phpunit": "^9.5", "rector/rector": "^0.14.5", "symfony/browser-kit": "^6.2", diff --git a/composer.lock b/composer.lock index 3f8918b3..65a17451 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,112 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3c107b1a514de87fe65342b40036a85d", + "content-hash": "13208988da7d237ad05740d9e74749b9", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "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/2.0.8" + }, + "time": "2022-12-07T17:46:57+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "8e6b6ea76eabbf19ea2bf5b67b98e1860474012f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8e6b6ea76eabbf19ea2bf5b67b98e1860474012f", + "reference": "8e6b6ea76eabbf19ea2bf5b67b98e1860474012f", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 | ^8 | ^9", + "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.4" + }, + "time": "2023-03-01T18:44:03+00:00" + }, { "name": "doctrine/annotations", "version": "1.14.3", @@ -1458,6 +1562,81 @@ ], "time": "2023-01-14T14:17:03+00:00" }, + { + "name": "endroid/qr-code", + "version": "4.8.2", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "2436c2333a3931c95e2b96eb82f16f53143d6bba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/2436c2333a3931c95e2b96eb82f16f53143d6bba", + "reference": "2436c2333a3931c95e2b96eb82f16f53143d6bba", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^2.0.5", + "php": "^8.0" + }, + "conflict": { + "khanamiryan/qrcode-detector-decoder": "^1.0.6" + }, + "require-dev": { + "endroid/quality": "dev-master", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^1.0.4||^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-master": "4.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/4.8.2" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2023-03-30T18:46:02+00:00" + }, { "name": "friendsofphp/proxy-manager-lts", "version": "v1.0.14", @@ -1967,6 +2146,73 @@ ], "time": "2023-02-06T13:46:10+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "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": "2022-06-14T06:56:20+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -2438,6 +2684,204 @@ }, "time": "2021-07-14T16:46:02+00:00" }, + { + "name": "scheb/2fa-bundle", + "version": "v6.8.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "4f8e9e87f90cf50c72b0857ea2b88453cf1d2446" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/4f8e9e87f90cf50c72b0857ea2b88453cf1d2446", + "reference": "4f8e9e87f90cf50c72b0857ea2b88453cf1d2446", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0", + "symfony/config": "^5.4 || ^6.0", + "symfony/dependency-injection": "^5.4 || ^6.0", + "symfony/event-dispatcher": "^5.4 || ^6.0", + "symfony/framework-bundle": "^5.4 || ^6.0", + "symfony/http-foundation": "^5.4 || ^6.0", + "symfony/http-kernel": "^5.4 || ^6.0", + "symfony/property-access": "^5.4 || ^6.0", + "symfony/security-bundle": "^5.4 || ^6.0", + "symfony/twig-bundle": "^5.4 || ^6.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/v6.8.0" + }, + "time": "2023-01-26T18:47:22+00:00" + }, + { + "name": "scheb/2fa-google-authenticator", + "version": "v6.8.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-google-authenticator.git", + "reference": "20eab4c1814b587cac71c4516a06b192ca838294" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/20eab4c1814b587cac71c4516a06b192ca838294", + "reference": "20eab4c1814b587cac71c4516a06b192ca838294", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.4", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^10.0 || ^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/v6.8.0" + }, + "time": "2022-12-10T15:20:09+00:00" + }, + { + "name": "spomky-labs/otphp", + "version": "11.2.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "9a1569038bb1c8e98040b14b8bcbba54f25e7795" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/9a1569038bb1c8e98040b14b8bcbba54f25e7795", + "reference": "9a1569038bb1c8e98040b14b8bcbba54f25e7795", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0", + "infection/infection": "^0.26", + "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", + "qossmic/deptrac-shim": "^1.0", + "rector/rector": "^0.15", + "symfony/phpunit-bridge": "^6.1", + "symplify/easy-coding-standard": "^11.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.2.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2023-03-16T19:16:25+00:00" + }, { "name": "symfony/asset", "version": "v6.2.7", @@ -9042,6 +9486,54 @@ }, "time": "2023-02-09T12:12:19+00:00" }, + { + "name": "phpgangsta/googleauthenticator", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/PHPGangsta/GoogleAuthenticator.git", + "reference": "505c2af8337b559b33557f37cda38e5f843f3768" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPGangsta/GoogleAuthenticator/zipball/505c2af8337b559b33557f37cda38e5f843f3768", + "reference": "505c2af8337b559b33557f37cda38e5f843f3768", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "default-branch": true, + "type": "library", + "autoload": { + "classmap": [ + "PHPGangsta/GoogleAuthenticator.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-4-Clause" + ], + "authors": [ + { + "name": "Michael Kliewe", + "email": "info@phpgangsta.de", + "homepage": "http://www.phpgangsta.de/", + "role": "Developer" + } + ], + "description": "Google Authenticator 2-factor authentication", + "keywords": [ + "googleauthenticator", + "rfc6238", + "totp" + ], + "support": { + "issues": "https://github.com/PHPGangsta/GoogleAuthenticator/issues", + "source": "https://github.com/PHPGangsta/GoogleAuthenticator" + }, + "time": "2019-03-20T00:55:58+00:00" + }, { "name": "phpstan/phpstan", "version": "1.10.13", diff --git a/config/bundles.php b/config/bundles.php index b7dc3d70..ee0f6489 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,4 +15,5 @@ Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::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..6e38cf2d --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,16 @@ +scheb_two_factor: + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken + - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface + + google: + enabled: true + #server_name: server_name # Server name used in QR code + issuer: issuer_placeholder # Issuer name used in QR code + digits: 6 # Number of digits in authentication code + window: 1 # Depends on the version of Spomky-Labs/otphp used: + # Until v10: How many codes before/after the current one would be accepted + # From v11: Acceptable time drift in seconds + template: auth/two_factor/two_factor_form.html.twig # Template used to render the authentication form + form_renderer: App\Controller\Auth\TwoFactor\EnterAuthCodeController diff --git a/config/packages/security.yaml b/config/packages/security.yaml index fcc91cf1..1bed4c6c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -30,6 +30,12 @@ security: lazy: true login_throttling: max_attempts: 5 + 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 + enable_csrf: true + csrf_parameter: _csrf_security_token + csrf_token_id: two_factor # This allows the user to login by submitting a username and password # Reference: https://symfony.com/doc/current/security/form_login_setup.html @@ -74,6 +80,11 @@ security: # additional security lives in the controllers - { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN } - { path: '^/(%app_locales%)/user', roles: ROLE_USER } + # This makes the logout route accessible during two-factor authentication. Allows the user to + # cancel two-factor authentication, if they need to. + - { path: ^/logout, role: PUBLIC_ACCESS } + # This ensures that the form can only be accessed when two-factor authentication is in progress. + - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } when@test: # this configuration simplifies testing URLs protected by the security mechanism diff --git a/config/routes/sched_2fa.yaml b/config/routes/sched_2fa.yaml new file mode 100644 index 00000000..33020f10 --- /dev/null +++ b/config/routes/sched_2fa.yaml @@ -0,0 +1,7 @@ +2fa_login: + path: /{_locale}/2fa + defaults: + _controller: "scheb_two_factor.form_controller::form" + +2fa_login_check: + path: /{_locale}/2fa_check diff --git a/migrations/Version20230501134351.php b/migrations/Version20230501134351.php new file mode 100644 index 00000000..f7d9709b --- /dev/null +++ b/migrations/Version20230501134351.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE users ADD google_authenticator_secret VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE users DROP google_authenticator_secret'); + } +} diff --git a/public/build/css/security.64e38d75.css b/public/build/css/security.64e38d75.css new file mode 100644 index 00000000..0485693e --- /dev/null +++ b/public/build/css/security.64e38d75.css @@ -0,0 +1 @@ +.security-buttons a i{background-color:#fff;border-radius:50%;color:#bdc3c7;padding:20px} \ No newline at end of file diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 188cfe8e..43745894 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -75,7 +75,7 @@ "js/user": { "js": [ "/build/runtime.9a71ee5d.js", - "/build/js/user.051ef9ab.js" + "/build/js/user.fa1131dc.js" ] }, "js/password": { @@ -84,6 +84,12 @@ "/build/js/password.a4f1763f.js" ] }, + "js/google_authenticator": { + "js": [ + "/build/runtime.9a71ee5d.js", + "/build/js/google_authenticator.0889962e.js" + ] + }, "js/bottom-bar": { "js": [ "/build/runtime.9a71ee5d.js", @@ -153,6 +159,14 @@ "css": [ "/build/css/bottom-bar.38e93f4e.css" ] + }, + "css/security": { + "js": [ + "/build/runtime.9a71ee5d.js" + ], + "css": [ + "/build/css/security.64e38d75.css" + ] } } } \ No newline at end of file diff --git a/public/build/js/google_authenticator.0889962e.js b/public/build/js/google_authenticator.0889962e.js new file mode 100644 index 00000000..10964ff0 --- /dev/null +++ b/public/build/js/google_authenticator.0889962e.js @@ -0,0 +1 @@ +(self.webpackChunk=self.webpackChunk||[]).push([[931],{861:()=>{!function(e){"use strict";const t=e("#generate_google_auth_secret"),a=e('[name="auth_token"]').val(),n=e('[name="generatedSecret"]'),o=e('[name="authentication_code"]');e("#setUpAuthenticatorButton").click((function(){!0===t.data("generate-new-secret")&&e.ajax({method:"GET",url:"/en/user/google_authenticator_code",data:{csrf_token:a}}).done((function(t){const{secret:a,qr_code:o}=t,c=new Image;c.src=o,n.val(a),e("#generatedQrCode").html(c),e("#generatedSecret").html(a)}))})),e("#enable2fa").click((function(){let c=o.val().trim();c?e.ajax({method:"PUT",url:t.attr("action"),data:{csrf_token:a,secret:n.val(),authentication_code:c}}).done((function(){location.reload()})).fail((function(t){var a;a=t.responseJSON.message,e("#twoFactorAuthErrorMessage").text(a).removeClass("d-none")})):o.addClass("is-invalid").focus()})),e("#disable2fa").click((function(){e.ajax({method:"DELETE",url:t.attr("action"),data:{csrf_token:a}}).done((function(){location.reload()})).fail((function(){location.reload()}))})),o.keyup((function(){e(this).removeClass("is-invalid")})),e("#setUpAuthenticator").on("hidden.bs.modal",(function(){o.val("").removeClass("is-invalid"),e("#twoFactorAuthErrorMessage").text("").addClass("d-none")}))}(window.jQuery)}},e=>{var t;t=861,e(e.s=t)}]); \ No newline at end of file diff --git a/public/build/js/user.051ef9ab.js b/public/build/js/user.fa1131dc.js similarity index 56% rename from public/build/js/user.051ef9ab.js rename to public/build/js/user.fa1131dc.js index a73bb80e..cabbed2c 100644 --- a/public/build/js/user.051ef9ab.js +++ b/public/build/js/user.fa1131dc.js @@ -1 +1 @@ -(self.webpackChunk=self.webpackChunk||[]).push([[733],{509:()=>{!function(t,e){"use strict";t('[data-type="delete"]').click((function(a){a.preventDefault();const s=t(this).closest("form"),i=t(this).data("message"),n=t(this).data("confirmation-text"),c=t(this).data("cancellation-text");e.confirm({message:i,buttons:{cancel:{label:c,className:"btn-light"},confirm:{label:n,className:"btn-danger"}},callback:function(t){t&&s.submit()}})}))}($,bootbox)},799:()=>{!function(t){"use strict";t(".btn-outline-secondary").click((function(e){e.preventDefault(),t(this).addClass("disabled");let a=t(this).attr("href"),s=t(this).parent().parent().parent();s.css({opacity:"0.5"}),t.get(a).done((function(){s.fadeOut(),function(){let e=t(".js-counter"),a=e.text();a=Number.parseInt(a),a-=1,e.text(a)}()}))}))}($)},437:()=>{!function(t){"use strict";let e=window.location.href;-1!==e.indexOf("profile")?t(".list-group-item-action:eq(2)").addClass("active"):-1!==e.indexOf("unpublished")?t(".list-group-item-action:eq(1)").addClass("active"):t(".list-group-item-action:eq(0)").addClass("active")}($)},346:(t,e,a)=>{"use strict";a(437),a(799),a(509)}},t=>{var e;e=346,t(t.s=e)}]); \ No newline at end of file +(self.webpackChunk=self.webpackChunk||[]).push([[733],{509:()=>{!function(t,e){"use strict";t('[data-type="delete"]').click((function(a){a.preventDefault();const i=t(this).closest("form"),s=t(this).data("message"),n=t(this).data("confirmation-text"),c=t(this).data("cancellation-text");e.confirm({message:s,buttons:{cancel:{label:c,className:"btn-light"},confirm:{label:n,className:"btn-danger"}},callback:function(t){t&&i.submit()}})}))}($,bootbox)},799:()=>{!function(t){"use strict";t(".btn-outline-secondary").click((function(e){e.preventDefault(),t(this).addClass("disabled");let a=t(this).attr("href"),i=t(this).parent().parent().parent();i.css({opacity:"0.5"}),t.get(a).done((function(){i.fadeOut(),function(){let e=t(".js-counter"),a=e.text();a=Number.parseInt(a),a-=1,e.text(a)}()}))}))}($)},437:()=>{!function(t){"use strict";let e=window.location.href;-1!==e.indexOf("profile")?t(".list-group-item-action:eq(2)").addClass("active"):-1!==e.indexOf("unpublished")?t(".list-group-item-action:eq(1)").addClass("active"):-1!==e.indexOf("security")?t(".list-group-item-action:eq(3)").addClass("active"):t(".list-group-item-action:eq(0)").addClass("active")}(window.jQuery)},346:(t,e,a)=>{"use strict";a(437),a(799),a(509)}},t=>{var e;e=346,t(t.s=e)}]); \ No newline at end of file diff --git a/public/build/manifest.json b/public/build/manifest.json index 1a68e912..1b0a721b 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -11,8 +11,9 @@ "build/js/city.js": "/build/js/city.0d36870f.js", "build/js/photo.js": "/build/js/photo.452a0a9f.js", "build/js/page.js": "/build/js/page.eaed21af.js", - "build/js/user.js": "/build/js/user.051ef9ab.js", + "build/js/user.js": "/build/js/user.fa1131dc.js", "build/js/password.js": "/build/js/password.a4f1763f.js", + "build/js/google_authenticator.js": "/build/js/google_authenticator.0889962e.js", "build/js/bottom-bar.js": "/build/js/bottom-bar.8f444bd6.js", "build/css/app.css": "/build/css/app.66361058.css", "build/css/admin.css": "/build/css/admin.832d2af4.css", @@ -22,6 +23,7 @@ "build/css/photo.css": "/build/css/photo.a213acff.css", "build/css/select2.css": "/build/css/select2.3f146cf8.css", "build/css/bottom-bar.css": "/build/css/bottom-bar.38e93f4e.css", + "build/css/security.css": "/build/css/security.64e38d75.css", "build/runtime.js": "/build/runtime.9a71ee5d.js", "build/images/fa-solid-900.svg": "/build/images/fa-solid-900.7a8b4f13.svg", "build/fonts/fa-solid-900.eot": "/build/fonts/fa-solid-900.9bbb245e.eot", diff --git a/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php b/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php new file mode 100644 index 00000000..c901628e --- /dev/null +++ b/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php @@ -0,0 +1,69 @@ +getUser(); + + return new JsonResponse($this->service->generateSecret($user)); + } catch (\Throwable $e) { + return new JsonResponse([ + 'message' => $e->getMessage(), + ], 422); + } + } + + #[Route(path: '/user/google_authenticator_code', name: 'set_auth_code', methods: ['PUT'])] + public function setAuthCode(Request $request): JsonResponse + { + try { + /** @var User $user */ + $user = $this->getUser(); + $authenticationCode = $request->get('authentication_code'); + $secret = $request->get('secret'); + + $this->service->setSecret($user, $secret, $authenticationCode); + + return new JsonResponse(); + } catch (\Throwable $exception) { + return new JsonResponse([ + 'message' => $exception->getMessage(), + ], 422); + } + } + + #[Route(path: '/user/google_authenticator_code', name: 'delete_auth_code', methods: ['DELETE'])] + public function deleteAuthCode(): JsonResponse + { + try { + /** @var User $user */ + $user = $this->getUser(); + $this->service->deleteSecret($user); + } catch (\Throwable) { + $this->addFlash('danger', '2fa.errors.cannot_disable_ga'); + } + + return new JsonResponse(); + } +} diff --git a/src/Controller/Ajax/User/PasswordController.php b/src/Controller/Ajax/User/Security/PasswordController.php similarity index 94% rename from src/Controller/Ajax/User/PasswordController.php rename to src/Controller/Ajax/User/Security/PasswordController.php index 28fd7acb..3b5cc410 100644 --- a/src/Controller/Ajax/User/PasswordController.php +++ b/src/Controller/Ajax/User/Security/PasswordController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller\Ajax\User; +namespace App\Controller\Ajax\User\Security; use App\Controller\Ajax\AjaxController; use App\Service\User\PasswordService; diff --git a/src/Controller/Auth/ResetPasswordController.php b/src/Controller/Auth/ResetPasswordController.php index 5518eab8..3afc7a6a 100644 --- a/src/Controller/Auth/ResetPasswordController.php +++ b/src/Controller/Auth/ResetPasswordController.php @@ -17,7 +17,7 @@ final class ResetPasswordController extends BaseController implements AuthController { - #[Route(path: '/password/reset', methods: ['GET|POST'], name: 'password_reset')] + #[Route(path: '/password/reset', name: 'password_reset', methods: ['GET|POST'])] public function passwordReset(ResettingService $service, Request $request): Response { $form = $this->createForm(UserEmailType::class, []); @@ -34,7 +34,7 @@ public function passwordReset(ResettingService $service, Request $request): Resp ]); } - #[Route(path: '/password/reset/{token}', methods: ['GET|POST'], name: 'password_reset_confirm')] + #[Route(path: '/password/reset/{token}', name: 'password_reset_confirm', methods: ['GET|POST'])] public function passwordResetConfirm(ResettingRepository $repository, Request $request, string $token): Response { /** @var User $user */ diff --git a/src/Controller/Auth/TwoFactor/EnterAuthCodeController.php b/src/Controller/Auth/TwoFactor/EnterAuthCodeController.php new file mode 100644 index 00000000..136b423f --- /dev/null +++ b/src/Controller/Auth/TwoFactor/EnterAuthCodeController.php @@ -0,0 +1,55 @@ +settingsRepository->findAllAsArray(); + + $this->setDoctrine($this->doctrine); + + $menu = $this->menu($request); + + return array_merge($settings, $menu); + } + + /** + * @throws SyntaxError + * @throws RuntimeError + * @throws LoaderError + */ + public function renderForm(Request $request, array $templateVars): Response + { + $content = $this->twigEnvironment->render('auth/two_factor/two_factor_form.html.twig', [ + 'robots' => 'noindex', + 'site' => $this->site($request), + ]); + + return (new Response()) + ->setContent($content); + } +} diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index a7afb088..8b7167a9 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -4,11 +4,11 @@ namespace App\Controller; +use App\Controller\Traits\MenuTrait; use App\Entity\Category; use App\Entity\City; use App\Entity\DealType; use App\Entity\Feature; -use App\Entity\Menu; use App\Repository\SettingsRepository; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -16,20 +16,12 @@ abstract class BaseController extends AbstractController { + use MenuTrait; + public function __construct(private readonly SettingsRepository $settingsRepository, protected ManagerRegistry $doctrine) { } - private function menu(Request $request): array - { - return [ - 'menu' => $this->doctrine->getRepository(Menu::class) - ->findBy([ - 'locale' => $request->getLocale(), - ], ['sort_order' => 'ASC']), - ]; - } - private function searchFields(): array { // Get city @@ -62,6 +54,8 @@ public function site(Request $request): array $fields = $this->searchFields(); + $this->setDoctrine($this->doctrine); + $menu = $this->menu($request); return array_merge($settings, $fields, $menu); diff --git a/src/Controller/Traits/MenuTrait.php b/src/Controller/Traits/MenuTrait.php new file mode 100644 index 00000000..0fb13c83 --- /dev/null +++ b/src/Controller/Traits/MenuTrait.php @@ -0,0 +1,29 @@ +doctrine = $doctrine; + } + + protected function menu(Request $request): array + { + return [ + 'menu' => $this->doctrine->getRepository(Menu::class) + ->findBy([ + 'locale' => $request->getLocale(), + ], ['sort_order' => 'ASC']), + ]; + } +} diff --git a/src/Controller/User/SecurityController.php b/src/Controller/User/SecurityController.php new file mode 100644 index 00000000..90feec87 --- /dev/null +++ b/src/Controller/User/SecurityController.php @@ -0,0 +1,21 @@ +render('user/security/security.html.twig', [ + 'site' => $this->site($request), + ]); + } +} diff --git a/src/Entity/Traits/TwoFactorTrait.php b/src/Entity/Traits/TwoFactorTrait.php new file mode 100644 index 00000000..8ef17ec6 --- /dev/null +++ b/src/Entity/Traits/TwoFactorTrait.php @@ -0,0 +1,34 @@ +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/src/Entity/User.php b/src/Entity/User.php index f7cbabd4..d962b182 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,10 +5,12 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Entity\Traits\TwoFactorTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -17,9 +19,10 @@ #[ORM\Table(name: 'users')] #[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')] #[UniqueEntity('email')] -class User implements UserInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface { use EntityIdTrait; + use TwoFactorTrait; /** * Requests older than this many seconds will be considered expired. */ @@ -28,6 +31,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * Maximum time that the confirmation token will be valid. */ public const TOKEN_TTL = 43200; + /** * @var string */ diff --git a/src/EventSubscriber/ControllerSubscriber.php b/src/EventSubscriber/ControllerSubscriber.php index 1702d208..0a382b17 100644 --- a/src/EventSubscriber/ControllerSubscriber.php +++ b/src/EventSubscriber/ControllerSubscriber.php @@ -8,6 +8,7 @@ use App\Controller\Auth\AuthController; use App\Middleware\ThrottleRequests; use App\Middleware\VerifyCsrfToken; +use Scheb\TwoFactorBundle\Controller\FormController; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -27,7 +28,7 @@ public function onKernelController(ControllerEvent $event): void if ($controller instanceof AjaxController) { $this->verifyCsrfToken->handle($event->getRequest()); - } elseif ($controller instanceof AuthController) { + } elseif ($controller instanceof AuthController || $controller instanceof FormController) { $this->throttleRequests->handle($event->getRequest()); } } diff --git a/src/Service/User/GoogleAuthenticatorAdapter.php b/src/Service/User/GoogleAuthenticatorAdapter.php new file mode 100644 index 00000000..ac2aeca9 --- /dev/null +++ b/src/Service/User/GoogleAuthenticatorAdapter.php @@ -0,0 +1,37 @@ +authenticator->checkCode($user, $code); + } + + public function getQRContent(TwoFactorInterface $user): string + { + return str_replace( + 'issuer_placeholder', + ucfirst($this->requestStack->getCurrentRequest()->getHost()), + $this->authenticator->getQRContent($user) + ); + } + + public function generateSecret(): string + { + return $this->authenticator->generateSecret(); + } +} diff --git a/src/Service/User/GoogleAuthenticatorService.php b/src/Service/User/GoogleAuthenticatorService.php new file mode 100644 index 00000000..d9100034 --- /dev/null +++ b/src/Service/User/GoogleAuthenticatorService.php @@ -0,0 +1,95 @@ +isGoogleAuthenticatorEnabled()) { + throw new \LogicException($this->translator->trans('2fa.errors.secret_is_already_set')); + } + + $secret = $this->googleAuthenticator->generateSecret(); + $user->setGoogleAuthenticatorSecret($secret); + $qrContent = $this->googleAuthenticator->getQRContent($user); + + parse_str($qrContent, $queryArray); + + return [ + 'secret' => $queryArray['secret'], + 'qr_code' => $this->getEncodedQrCode($qrContent), + ]; + } + + /** + * @throws \Exception + */ + public function setSecret(TwoFactorInterface $user, ?string $secret, ?string $authenticationCode): void + { + if (!$authenticationCode || !$secret) { + throw new \Exception($this->translator->trans('2fa.errors.cannot_enable_ga')); + } + + $user->setGoogleAuthenticatorSecret($secret); + + if (!$this->googleAuthenticator->checkCode($user, $authenticationCode)) { + throw new \Exception($this->translator->trans('2fa.errors.incorrect_ga_code')); + } + + $this->entityManager->flush(); + $this->addFlash('success', '2fa.messages.enabled'); + } + + public function deleteSecret(TwoFactorInterface $user): void + { + $user->setGoogleAuthenticatorSecret(null); + $this->entityManager->flush(); + $this->addFlash('success', '2fa.messages.disabled'); + } + + private function getEncodedQrCode(string $qrCodeContent): string + { + $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 'data:image/png;base64,'.base64_encode($result->getString()); + } +} diff --git a/symfony.lock b/symfony.lock index d0b5266e..f1f68f18 100644 --- a/symfony.lock +++ b/symfony.lock @@ -76,6 +76,19 @@ "tests/bootstrap.php" ] }, + "scheb/2fa-bundle": { + "version": "6.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "symfony/console": { "version": "6.2", "recipe": { diff --git a/templates/auth/two_factor/two_factor_form.html.twig b/templates/auth/two_factor/two_factor_form.html.twig new file mode 100644 index 00000000..da247f5f --- /dev/null +++ b/templates/auth/two_factor/two_factor_form.html.twig @@ -0,0 +1,51 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}{{ 'title.login' | trans }}{% endblock %} + +{% block body %} + +
+
+
+
+
{{ '2fa.google_authenticator_code'|trans }}
+
+ +
+ + + +
+ + +
+ +
+
+ + + + {{ 'action.cancel'|trans }} + +
+
+ +
+ +
+
+
+
+ +{% endblock %} diff --git a/templates/user/common/_floating_action_button.html.twig b/templates/user/common/_floating_action_button.html.twig new file mode 100644 index 00000000..5e868f6f --- /dev/null +++ b/templates/user/common/_floating_action_button.html.twig @@ -0,0 +1,13 @@ +{% if is_granted('ROLE_ADMIN') %} + + + + + +{% elseif app.user.isVerified %} + + + + + +{% endif %} diff --git a/templates/user/common/_sidebar.html.twig b/templates/user/common/_sidebar.html.twig index 8f7306e0..feba3a25 100644 --- a/templates/user/common/_sidebar.html.twig +++ b/templates/user/common/_sidebar.html.twig @@ -1,23 +1,25 @@ -
-
-

{{ 'title.menu'|trans }}

+
+
+
+

{{ 'title.menu'|trans }}

+
-
- diff --git a/templates/user/photo/edit.html.twig b/templates/user/photo/edit.html.twig index 1b4eddf4..7fca59ea 100644 --- a/templates/user/photo/edit.html.twig +++ b/templates/user/photo/edit.html.twig @@ -84,11 +84,7 @@
-
- - {{ include('user/common/_sidebar.html.twig') }} - {{ include('user/common/_change_password.html.twig') }} -
+ {{ include('user/common/_sidebar.html.twig') }}
{% endblock %} @@ -96,7 +92,6 @@ {% block javascripts %} {{ encore_entry_script_tags('js/photo') }} - {{ encore_entry_script_tags('js/password') }} {{ encore_entry_script_tags('js/user') }} {% endblock %} diff --git a/templates/user/profile/profile.html.twig b/templates/user/profile/profile.html.twig index ccfe9747..59cb1ab2 100644 --- a/templates/user/profile/profile.html.twig +++ b/templates/user/profile/profile.html.twig @@ -35,32 +35,16 @@ -
- - {{ include('user/common/_sidebar.html.twig') }} - {{ include('user/common/_change_password.html.twig') }} -
+ {{ include('user/common/_sidebar.html.twig') }} - {% if is_granted('ROLE_ADMIN') %} - - - - - - {% elseif app.user.isVerified %} - - - - + {{ include('user/common/_floating_action_button.html.twig') }} - {% endif %} {% endblock %} {% block javascripts %} {{ encore_entry_script_tags('js/user') }} - {{ encore_entry_script_tags('js/password') }} {% endblock %} diff --git a/templates/user/property/edit.html.twig b/templates/user/property/edit.html.twig index e19780ed..5b101004 100644 --- a/templates/user/property/edit.html.twig +++ b/templates/user/property/edit.html.twig @@ -141,11 +141,7 @@ -
- - {{ include('user/common/_sidebar.html.twig') }} - {{ include('user/common/_change_password.html.twig') }} -
+ {{ include('user/common/_sidebar.html.twig') }} {% endblock %} @@ -154,7 +150,6 @@ {{ encore_entry_script_tags('js/city') }} {{ encore_entry_script_tags('js/select2') }} - {{ encore_entry_script_tags('js/password') }} {% if isHtmlAllowed == true %} diff --git a/templates/user/property/index.html.twig b/templates/user/property/index.html.twig index 1e35ae50..8edc021f 100644 --- a/templates/user/property/index.html.twig +++ b/templates/user/property/index.html.twig @@ -19,27 +19,11 @@ {{ include('user/property/partials/_properties.html.twig') }} -
- - {{ include('user/common/_sidebar.html.twig') }} - {{ include('user/common/_change_password.html.twig') }} -
+ {{ include('user/common/_sidebar.html.twig') }} - {% if is_granted('ROLE_ADMIN') %} - - - - - - {% elseif app.user.isVerified %} - - - - - - {% endif %} + {{ include('user/common/_floating_action_button.html.twig') }} {{ knp_pagination_render(properties) }} @@ -48,6 +32,5 @@ {% block javascripts %} {{ encore_entry_script_tags('js/user') }} - {{ encore_entry_script_tags('js/password') }} {% endblock %} diff --git a/templates/user/property/new.html.twig b/templates/user/property/new.html.twig index 4b2c9cd2..239c5f8b 100644 --- a/templates/user/property/new.html.twig +++ b/templates/user/property/new.html.twig @@ -141,11 +141,7 @@ -
- - {{ include('user/common/_sidebar.html.twig') }} - {{ include('user/common/_change_password.html.twig') }} -
+ {{ include('user/common/_sidebar.html.twig') }} {% endblock %} @@ -154,7 +150,6 @@ {{ encore_entry_script_tags('js/city') }} {{ encore_entry_script_tags('js/select2') }} - {{ encore_entry_script_tags('js/password') }} {% if isHtmlAllowed == true %} diff --git a/templates/user/common/_change_password.html.twig b/templates/user/security/_change_password.html.twig similarity index 98% rename from templates/user/common/_change_password.html.twig rename to templates/user/security/_change_password.html.twig index 5960eb41..e6314044 100644 --- a/templates/user/common/_change_password.html.twig +++ b/templates/user/security/_change_password.html.twig @@ -52,7 +52,7 @@ + + + + + diff --git a/templates/user/security/security.html.twig b/templates/user/security/security.html.twig new file mode 100644 index 00000000..9b4edfcf --- /dev/null +++ b/templates/user/security/security.html.twig @@ -0,0 +1,67 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}{{ 'title.security'|trans }}{% endblock %} + + {% block stylesheets %} + {{ encore_entry_link_tags('css/security') }} + {% endblock %} + +{% block body %} + +
+ +
+ +
+
+

{{ 'title.security'|trans }}

+
+
+ + +
+ + {{ include('user/common/_sidebar.html.twig') }} + +
+ + {{ include('user/common/_floating_action_button.html.twig') }} + {{ include('user/security/_change_password.html.twig') }} + {{ include('user/security/_google_authenticator.html.twig') }} + +{% endblock %} + +{% block javascripts %} + + {{ encore_entry_script_tags('js/user') }} + {{ encore_entry_script_tags('js/password') }} + {{ encore_entry_script_tags('js/google_authenticator') }} + +{% endblock %} diff --git a/tests/E2E/User/PasswordChangeTest.php b/tests/E2E/User/PasswordChangeTest.php index b1065232..7694803b 100644 --- a/tests/E2E/User/PasswordChangeTest.php +++ b/tests/E2E/User/PasswordChangeTest.php @@ -26,6 +26,7 @@ public function testPasswordChange(): void // Log In as a User $client = self::createPantherClient(); $this->login($client, 'user', 'user'); + $client->clickLink('Security'); $client->waitFor('[data-target="#changePassword"]'); // Open the modal window diff --git a/tests/Functional/Controller/Ajax/CityControllerTest.php b/tests/Functional/Controller/Ajax/CityControllerTest.php index ba39c7c2..d8916cd3 100644 --- a/tests/Functional/Controller/Ajax/CityControllerTest.php +++ b/tests/Functional/Controller/Ajax/CityControllerTest.php @@ -7,7 +7,6 @@ use App\Entity\City; use App\Tests\Helper\WebTestHelper; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; -use Symfony\Component\HttpFoundation\Response; final class CityControllerTest extends WebTestCase { @@ -48,11 +47,4 @@ public function testSomething(): void $this->assertContainsWords($response, ['Miami', 'South Beach', 'Allapattah']); } - - private function assertContainsWords(Response $response, array $words): void - { - foreach ($words as $word) { - $this->assertStringContainsString($word, (string) $response->getContent()); - } - } } diff --git a/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php b/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php new file mode 100644 index 00000000..dddb7bb6 --- /dev/null +++ b/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php @@ -0,0 +1,131 @@ +authAsUser($this); + + $client->request('GET', self::ENDPOINT, []); + $this->assertResponseStatusCodeSame(419); + $this->assertJson($client->getResponse()->getContent()); + + $client->request('PUT', self::ENDPOINT, []); + $this->assertResponseStatusCodeSame(419); + $this->assertJson($client->getResponse()->getContent()); + + $client->request('DELETE', self::ENDPOINT, []); + $this->assertResponseStatusCodeSame(419); + $this->assertJson($client->getResponse()->getContent()); + } + + public function testGetAuthCode(): void + { + $client = $this->authAsUser($this); + $token = $this->getToken($client); + $this->assertNotEmpty($token); + $client->request('GET', self::ENDPOINT, [ + 'csrf_token' => $token, + ]); + $this->assertResponseIsSuccessful(); + $response = $client->getResponse(); + $this->assertJson($response->getContent()); + $this->assertTrue(\strlen($response->getContent()) > 2000 && \strlen($response->getContent()) < 3100); + $this->assertContainsWords($response, ['secret', 'qr_code', 'data:image', 'png;base64']); + } + + /** + * @throws \Exception + */ + public function testSetInvalidAuthCode(): void + { + // Send empty data + $client = $this->authAsUser($this); + $token = $this->getToken($client); + $client->request('PUT', self::ENDPOINT, [ + 'csrf_token' => $token, + ]); + $this->assertResponseIsUnprocessable(); + $response = $client->getResponse(); + $this->assertJson($response->getContent()); + $this->assertContainsWords($response, ['Cannot enable Google Authenticator']); + + // Set wrong data + $client->request('PUT', self::ENDPOINT, [ + 'secret' => self::SECRET, + 'authentication_code' => random_int(100000, 999999), + 'csrf_token' => $token, + ]); + $this->assertResponseIsUnprocessable(); + $response = $client->getResponse(); + $this->assertJson($response->getContent()); + $this->assertContainsWords($response, ['The Google Authenticator code is incorrect or has expired']); + } + + public function testSetAuthCode(): void + { + $client = $this->authAsUser($this); + $token = $this->getToken($client); + + $user = $this->getUser($client, 'user'); + + $user->setGoogleAuthenticatorSecret(self::SECRET); + + $ga = new \PHPGangsta_GoogleAuthenticator(); + $oneTimePassword = $ga->getCode(self::SECRET); + + $client->request('PUT', self::ENDPOINT, [ + 'secret' => self::SECRET, + 'authentication_code' => $oneTimePassword, + 'csrf_token' => $token, + ]); + + $this->assertResponseIsSuccessful(); + + $updatedUser = $this->getRepository($client, User::class)->findOneBy([ + 'username' => 'user', + ]); + + $this->assertTrue($updatedUser->isGoogleAuthenticatorEnabled()); + } + + public function testDeleteAuthCode(): void + { + $client = $this->authAsUser($this); + $token = $this->getToken($client); + + $client->request('DELETE', self::ENDPOINT, [ + 'csrf_token' => $token, + ]); + + $this->assertResponseIsSuccessful(); + + $updatedUser = $this->getRepository($client, User::class)->findOneBy([ + 'username' => 'user', + ]); + + $this->assertFalse($updatedUser->isGoogleAuthenticatorEnabled()); + } + + private function getToken(KernelBrowser $client): string + { + $crawler = $client->request('GET', '/en/user/security'); + + return $crawler->filter('[name="auth_token"]')->attr('value'); + } +} diff --git a/tests/Functional/Controller/User/PasswordControllerTest.php b/tests/Functional/Controller/User/Security/PasswordControllerTest.php similarity index 94% rename from tests/Functional/Controller/User/PasswordControllerTest.php rename to tests/Functional/Controller/User/Security/PasswordControllerTest.php index 4c646a90..c6f9f4dd 100644 --- a/tests/Functional/Controller/User/PasswordControllerTest.php +++ b/tests/Functional/Controller/User/Security/PasswordControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Functional\Controller\User; +namespace App\Tests\Functional\Controller\User\Security; use App\Tests\Helper\WebTestHelper; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -63,7 +63,7 @@ public function testPasswordChange(): void private function getToken(KernelBrowser $client): string { - $crawler = $client->request('GET', '/en/user/property'); + $crawler = $client->request('GET', '/en/user/security'); return $crawler->filter('[name="password_token"]')->attr('value'); } diff --git a/tests/Helper/WebTestHelper.php b/tests/Helper/WebTestHelper.php index 360802ba..fa180d9f 100644 --- a/tests/Helper/WebTestHelper.php +++ b/tests/Helper/WebTestHelper.php @@ -12,6 +12,7 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\HttpFoundation\Response; trait WebTestHelper { @@ -82,4 +83,11 @@ public function resetSettings(KernelBrowser $client): void $this->updateSettings($client, $settings); } + + private function assertContainsWords(Response $response, array $words): void + { + foreach ($words as $word) { + $this->assertStringContainsString($word, (string) $response->getContent()); + } + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 06b73d85..8cf165a8 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -54,10 +54,18 @@ menu.profile My Profile + + menu.security + Security + menu.password Change Password + + menu.google_auth + Set up Google Authenticator + menu.logout Log Out @@ -418,6 +426,10 @@ title.my_profile My profile + + title.security + Security + title.my_properties My properties @@ -1102,6 +1114,59 @@ Please wait... + + 2fa.google_authenticator_code + Google Authenticator code + + + 2fa.google_authenticator_code_label + Enter the 6-digit code from Google Authenticator + + + 2fa.buttons.authenticate + Authenticate + + + 2fa.buttons.enable + Enable two-factor authentication + + + 2fa.buttons.disable + Disable + + + 2fa.messages.scan + Please scan the QR code or enter the private key into the App first, then enter the verification code displayed on the Google Authenticator App into the field below. + + + 2fa.messages.enabled + Two-Factor authentication successfully enabled + + + 2fa.messages.disabled + Two-Factor authentication successfully disabled + + + 2fa.messages.active + Two-factor authentication is active. + + + 2fa.errors.secret_is_already_set + The secret is already set + + + 2fa.errors.cannot_enable_ga + Cannot enable Google Authenticator + + + 2fa.errors.cannot_disable_ga + Cannot enable Google Authenticator + + + 2fa.errors.incorrect_ga_code + The Google Authenticator code is incorrect or has expired + + email.new_message New Message From Your Website diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index f26b8a36..92672bc3 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -54,10 +54,18 @@ menu.profile Мой профиль + + menu.security + Безопасность + menu.password Изменить пароль + + menu.google_auth + Настроить Google Authenticator + menu.logout Выйти @@ -418,6 +426,10 @@ title.my_profile Мой профиль + + title.security + Безопасность + title.my_properties Мои объекты @@ -1102,6 +1114,59 @@ Подождите... + + 2fa.google_authenticator_code + Код из Google Authenticator + + + 2fa.google_authenticator_code_label + Введите 6-значный код из приложения Google Authenticator + + + 2fa.buttons.authenticate + Войти + + + 2fa.buttons.enable + Включить двухфакторную аутентификацию + + + 2fa.buttons.disable + Отключить + + + 2fa.messages.scan + Сначала отсканируйте QR-код или введите закрытый ключ в приложение, а затем введите код подтверждения, отображаемый в приложении Google Authenticator, в поле ниже. + + + 2fa.messages.enabled + Двухфакторная аутентификация успешно включена + + + 2fa.messages.disabled + Двухфакторная аутентификация успешно отключена + + + 2fa.messages.active + Двухфакторная аутентификация активна. + + + 2fa.errors.secret_is_already_set + Секрет уже установлен + + + 2fa.errors.cannot_enable_ga + Не удается включить Google Authenticator + + + 2fa.errors.cannot_disable_ga + Не удается отключить Google Authenticator + + + 2fa.errors.incorrect_ga_code + Код Google Authenticator неверен или срок его действия истек + + email.new_message Новое сообщение с вашего сайта diff --git a/webpack.config.js b/webpack.config.js index 3f4a0700..8d914a71 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -31,6 +31,7 @@ Encore .addEntry('js/page', './assets/js/page.js') .addEntry('js/user', './assets/js/user/user.js') .addEntry('js/password', './assets/js/user/password/password.js') + .addEntry('js/google_authenticator', './assets/js/user/two_factor/google_authenticator.js') .addEntry('js/bottom-bar', './assets/js/bottom-bar.js') // will require an extra script tag for runtime.js @@ -73,6 +74,7 @@ Encore .addStyleEntry('css/photo', ['./assets/styles/photo.scss']) .addStyleEntry('css/select2', ['./assets/styles/select2.scss']) .addStyleEntry('css/bottom-bar', ['./assets/styles/bottom-bar.scss']) + .addStyleEntry('css/security', ['./assets/styles/user/security.scss']) //.enableIntegrityHashes() .configureBabel(null, { useBuiltIns: 'usage', From a11b48de2c1e49f086d45cef3d1ab2faaacba224 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 09:21:53 +0300 Subject: [PATCH 2/9] Adjust tests/bootstrap.php --- phpunit.xml.dist | 1 + tests/bootstrap.php | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2f8af21f..890644a1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,7 @@ + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 90c3ef8c..0a683767 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,3 +11,12 @@ } elseif (method_exists(Dotenv::class, 'bootEnv')) { (new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); } + +if (isset($_ENV['BOOTSTRAP_CLEAR_CACHE_ENV'])) { + // executes the "php bin/console cache:clear" command + passthru(sprintf( + 'APP_ENV=%s php "%s/../bin/console" cache:clear --no-warmup', + $_ENV['BOOTSTRAP_CLEAR_CACHE_ENV'], + __DIR__ + )); +} From 6c511783f24d19689ddf4c87f34d18eb8869f5a8 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 09:36:19 +0300 Subject: [PATCH 3/9] Code refactoring --- src/Entity/Category.php | 5 +++-- src/Entity/City.php | 11 ++++++----- src/Entity/Currency.php | 3 ++- src/Entity/DealType.php | 5 +++-- src/Entity/District.php | 11 ++++++----- src/Entity/Feature.php | 5 +++-- src/Entity/Menu.php | 3 ++- src/Entity/Metro.php | 11 ++++++----- src/Entity/Neighborhood.php | 11 ++++++----- src/Entity/Page.php | 3 ++- src/Entity/Photo.php | 5 +++-- src/Entity/Property.php | 17 +++++++++-------- src/Entity/Settings.php | 3 ++- src/Entity/Traits/CityTrait.php | 2 +- src/Entity/Traits/EntityLocationTrait.php | 6 +++--- src/Entity/Traits/PropertyTrait.php | 2 +- src/Entity/User.php | 9 +++++---- src/EventSubscriber/ControllerSubscriber.php | 2 +- src/MessageHandler/DeletePhotosHandler.php | 2 +- .../SendEmailConfirmationLinkHandler.php | 11 ++++------- src/MessageHandler/SendFeedbackHandler.php | 2 +- .../SendResetPasswordLinkHandler.php | 7 +++++-- src/Validator/RegisteredUserValidator.php | 5 +++-- 23 files changed, 78 insertions(+), 63 deletions(-) diff --git a/src/Entity/Category.php b/src/Entity/Category.php index ddb023d9..8df9219b 100644 --- a/src/Entity/Category.php +++ b/src/Entity/Category.php @@ -7,10 +7,11 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityNameTrait; use App\Entity\Traits\PropertyTrait; +use App\Repository\CategoryRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\CategoryRepository')] +#[ORM\Entity(repositoryClass: CategoryRepository::class)] #[UniqueEntity('slug')] class Category { @@ -18,5 +19,5 @@ class Category use EntityNameTrait; use PropertyTrait; - public const MAPPED_BY = 'category'; + final public const MAPPED_BY = 'category'; } diff --git a/src/Entity/City.php b/src/Entity/City.php index b6cef3fe..b554d518 100644 --- a/src/Entity/City.php +++ b/src/Entity/City.php @@ -7,13 +7,14 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityMetaTrait; use App\Entity\Traits\EntityNameTrait; +use App\Repository\CityRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\CityRepository')] +#[ORM\Entity(repositoryClass: CityRepository::class)] #[UniqueEntity('slug')] class City { @@ -21,18 +22,18 @@ class City use EntityMetaTrait; use EntityNameTrait; - #[ORM\OneToMany(mappedBy: 'city', targetEntity: 'App\Entity\Property')] + #[ORM\OneToMany(mappedBy: 'city', targetEntity: Property::class)] private $properties; - #[ORM\OneToMany(mappedBy: 'city', targetEntity: 'App\Entity\District')] + #[ORM\OneToMany(mappedBy: 'city', targetEntity: District::class)] #[ORM\OrderBy(['name' => 'ASC'])] private $districts; - #[ORM\OneToMany(mappedBy: 'city', targetEntity: 'App\Entity\Neighborhood')] + #[ORM\OneToMany(mappedBy: 'city', targetEntity: Neighborhood::class)] #[ORM\OrderBy(['name' => 'ASC'])] private $neighborhoods; - #[ORM\OneToMany(mappedBy: 'city', targetEntity: 'App\Entity\Metro', orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'city', targetEntity: Metro::class, orphanRemoval: true)] #[ORM\OrderBy(['name' => 'ASC'])] private $metro_stations; diff --git a/src/Entity/Currency.php b/src/Entity/Currency.php index a48244db..be2d1278 100644 --- a/src/Entity/Currency.php +++ b/src/Entity/Currency.php @@ -5,10 +5,11 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\CurrencyRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity(repositoryClass: 'App\Repository\CurrencyRepository')] +#[ORM\Entity(repositoryClass: CurrencyRepository::class)] class Currency { use EntityIdTrait; diff --git a/src/Entity/DealType.php b/src/Entity/DealType.php index 50d3925c..4eb56822 100644 --- a/src/Entity/DealType.php +++ b/src/Entity/DealType.php @@ -7,10 +7,11 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityNameTrait; use App\Entity\Traits\PropertyTrait; +use App\Repository\DealTypeRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\DealTypeRepository')] +#[ORM\Entity(repositoryClass: DealTypeRepository::class)] #[UniqueEntity('slug')] class DealType { @@ -18,5 +19,5 @@ class DealType use EntityNameTrait; use PropertyTrait; - public const MAPPED_BY = 'deal_type'; + final public const MAPPED_BY = 'deal_type'; } diff --git a/src/Entity/District.php b/src/Entity/District.php index fd7b8c68..4fe71931 100644 --- a/src/Entity/District.php +++ b/src/Entity/District.php @@ -8,10 +8,11 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityNameTrait; use App\Entity\Traits\PropertyTrait; +use App\Repository\DistrictRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\DistrictRepository')] +#[ORM\Entity(repositoryClass: DistrictRepository::class)] #[UniqueEntity('slug')] class District { @@ -20,8 +21,8 @@ class District use EntityNameTrait; use PropertyTrait; - public const MAPPED_BY = 'district'; - public const INVERSED_BY = 'districts'; - public const GETTER = 'getDistrict'; - public const SETTER = 'setDistrict'; + final public const MAPPED_BY = 'district'; + final public const INVERSED_BY = 'districts'; + final public const GETTER = 'getDistrict'; + final public const SETTER = 'setDistrict'; } diff --git a/src/Entity/Feature.php b/src/Entity/Feature.php index 6b95f320..ed19b9f3 100644 --- a/src/Entity/Feature.php +++ b/src/Entity/Feature.php @@ -5,12 +5,13 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\FeatureRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity(repositoryClass: 'App\Repository\FeatureRepository')] +#[ORM\Entity(repositoryClass: FeatureRepository::class)] class Feature { use EntityIdTrait; @@ -18,7 +19,7 @@ class Feature #[ORM\Column(type: Types::STRING, length: 255)] private ?string $name; - #[ORM\ManyToMany(targetEntity: 'App\Entity\Property', mappedBy: 'features')] + #[ORM\ManyToMany(targetEntity: Property::class, mappedBy: 'features')] private $properties; #[ORM\Column(type: Types::TEXT, nullable: true)] diff --git a/src/Entity/Menu.php b/src/Entity/Menu.php index 266a49c4..7b4eadba 100644 --- a/src/Entity/Menu.php +++ b/src/Entity/Menu.php @@ -5,13 +5,14 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\MenuRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[ORM\Table] #[ORM\UniqueConstraint(name: 'url_locale_unique_key', columns: ['url', 'locale'])] -#[ORM\Entity(repositoryClass: 'App\Repository\MenuRepository')] +#[ORM\Entity(repositoryClass: MenuRepository::class)] #[UniqueEntity(['url', 'locale'])] class Menu { diff --git a/src/Entity/Metro.php b/src/Entity/Metro.php index c840bf8b..3bf761bb 100644 --- a/src/Entity/Metro.php +++ b/src/Entity/Metro.php @@ -8,10 +8,11 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityNameTrait; use App\Entity\Traits\PropertyTrait; +use App\Repository\MetroRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\MetroRepository')] +#[ORM\Entity(repositoryClass: MetroRepository::class)] #[UniqueEntity('slug')] class Metro { @@ -20,8 +21,8 @@ class Metro use EntityNameTrait; use PropertyTrait; - public const MAPPED_BY = 'metro_station'; - public const INVERSED_BY = 'metro_stations'; - public const GETTER = 'getMetroStation'; - public const SETTER = 'setMetroStation'; + final public const MAPPED_BY = 'metro_station'; + final public const INVERSED_BY = 'metro_stations'; + final public const GETTER = 'getMetroStation'; + final public const SETTER = 'setMetroStation'; } diff --git a/src/Entity/Neighborhood.php b/src/Entity/Neighborhood.php index 22f14c92..ab4e755a 100644 --- a/src/Entity/Neighborhood.php +++ b/src/Entity/Neighborhood.php @@ -8,10 +8,11 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityNameTrait; use App\Entity\Traits\PropertyTrait; +use App\Repository\NeighborhoodRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\NeighborhoodRepository')] +#[ORM\Entity(repositoryClass: NeighborhoodRepository::class)] #[UniqueEntity('slug')] class Neighborhood { @@ -20,8 +21,8 @@ class Neighborhood use EntityNameTrait; use PropertyTrait; - public const MAPPED_BY = 'neighborhood'; - public const INVERSED_BY = 'neighborhoods'; - public const GETTER = 'getNeighborhood'; - public const SETTER = 'setNeighborhood'; + final public const MAPPED_BY = 'neighborhood'; + final public const INVERSED_BY = 'neighborhoods'; + final public const GETTER = 'getNeighborhood'; + final public const SETTER = 'setNeighborhood'; } diff --git a/src/Entity/Page.php b/src/Entity/Page.php index e583cfa1..7226a23c 100644 --- a/src/Entity/Page.php +++ b/src/Entity/Page.php @@ -5,6 +5,7 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\PageRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -12,7 +13,7 @@ #[ORM\Table] #[ORM\UniqueConstraint(name: 'slug_locale_unique_key', columns: ['slug', 'locale'])] -#[ORM\Entity(repositoryClass: 'App\Repository\PageRepository')] +#[ORM\Entity(repositoryClass: PageRepository::class)] #[UniqueEntity(['slug', 'locale'])] class Page { diff --git a/src/Entity/Photo.php b/src/Entity/Photo.php index d799ca88..32f1c778 100644 --- a/src/Entity/Photo.php +++ b/src/Entity/Photo.php @@ -5,16 +5,17 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\PhotoRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; -#[ORM\Entity(repositoryClass: 'App\Repository\PhotoRepository')] +#[ORM\Entity(repositoryClass: PhotoRepository::class)] class Photo { use EntityIdTrait; - #[ORM\ManyToOne(targetEntity: 'App\Entity\Property', inversedBy: 'photos')] + #[ORM\ManyToOne(targetEntity: Property::class, inversedBy: 'photos')] #[ORM\JoinColumn(nullable: true)] private $property; diff --git a/src/Entity/Property.php b/src/Entity/Property.php index d200e989..f22f31e6 100644 --- a/src/Entity/Property.php +++ b/src/Entity/Property.php @@ -8,12 +8,13 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\EntityLocationTrait; use App\Entity\Traits\EntityTimestampableTrait; +use App\Repository\PropertyRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity(repositoryClass: 'App\Repository\PropertyRepository')] +#[ORM\Entity(repositoryClass: PropertyRepository::class)] class Property { use CityTrait; @@ -21,17 +22,17 @@ class Property use EntityLocationTrait; use EntityTimestampableTrait; - public const INVERSED_BY = 'properties'; + final public const INVERSED_BY = 'properties'; - #[ORM\ManyToOne(targetEntity: 'App\Entity\User', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: false)] private $author; - #[ORM\ManyToOne(targetEntity: 'App\Entity\DealType', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: DealType::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: false)] private $deal_type; - #[ORM\ManyToOne(targetEntity: 'App\Entity\Category', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: false)] private $category; @@ -60,13 +61,13 @@ class Property private $available_now; #[ORM\Column(type: Types::STRING, length: 255, options: ['default' => 'pending'])] - private $state = 'published'; + private string $state = 'published'; - #[ORM\OneToMany(mappedBy: 'property', targetEntity: 'App\Entity\Photo', orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'property', targetEntity: Photo::class, orphanRemoval: true)] #[ORM\OrderBy(['sort_order' => 'ASC'])] private $photos; - #[ORM\ManyToMany(targetEntity: 'App\Entity\Feature', inversedBy: 'properties')] + #[ORM\ManyToMany(targetEntity: Feature::class, inversedBy: 'properties')] private $features; #[ORM\Column(type: Types::INTEGER)] diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php index e56fda1e..36cb5d99 100644 --- a/src/Entity/Settings.php +++ b/src/Entity/Settings.php @@ -5,11 +5,12 @@ namespace App\Entity; use App\Entity\Traits\EntityIdTrait; +use App\Repository\SettingsRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[ORM\Entity(repositoryClass: 'App\Repository\SettingsRepository')] +#[ORM\Entity(repositoryClass: SettingsRepository::class)] #[UniqueEntity('setting_name')] class Settings { diff --git a/src/Entity/Traits/CityTrait.php b/src/Entity/Traits/CityTrait.php index f380622b..75f9104b 100644 --- a/src/Entity/Traits/CityTrait.php +++ b/src/Entity/Traits/CityTrait.php @@ -9,7 +9,7 @@ trait CityTrait { - #[ORM\ManyToOne(targetEntity: 'App\Entity\City', inversedBy: self::INVERSED_BY)] + #[ORM\ManyToOne(targetEntity: City::class, inversedBy: self::INVERSED_BY)] #[ORM\JoinColumn(nullable: false)] private $city; diff --git a/src/Entity/Traits/EntityLocationTrait.php b/src/Entity/Traits/EntityLocationTrait.php index 17e76549..ee5b7870 100644 --- a/src/Entity/Traits/EntityLocationTrait.php +++ b/src/Entity/Traits/EntityLocationTrait.php @@ -12,15 +12,15 @@ trait EntityLocationTrait { - #[ORM\ManyToOne(targetEntity: 'App\Entity\District', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: District::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: true)] private ?District $district; - #[ORM\ManyToOne(targetEntity: 'App\Entity\Neighborhood', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: Neighborhood::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: true)] private ?Neighborhood $neighborhood; - #[ORM\ManyToOne(targetEntity: 'App\Entity\Metro', inversedBy: 'properties')] + #[ORM\ManyToOne(targetEntity: Metro::class, inversedBy: 'properties')] #[ORM\JoinColumn(nullable: true)] private ?Metro $metro_station; diff --git a/src/Entity/Traits/PropertyTrait.php b/src/Entity/Traits/PropertyTrait.php index 39ae44df..a54a63de 100644 --- a/src/Entity/Traits/PropertyTrait.php +++ b/src/Entity/Traits/PropertyTrait.php @@ -11,7 +11,7 @@ trait PropertyTrait { - #[ORM\OneToMany(mappedBy: self::MAPPED_BY, targetEntity: 'App\Entity\Property')] + #[ORM\OneToMany(mappedBy: self::MAPPED_BY, targetEntity: Property::class)] private Collection $properties; public function __construct() diff --git a/src/Entity/User.php b/src/Entity/User.php index d962b182..6ace312f 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -6,6 +6,7 @@ use App\Entity\Traits\EntityIdTrait; use App\Entity\Traits\TwoFactorTrait; +use App\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -17,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'users')] -#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')] +#[ORM\Entity(repositoryClass: UserRepository::class)] #[UniqueEntity('email')] class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface { @@ -26,11 +27,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFact /** * Requests older than this many seconds will be considered expired. */ - public const RETRY_TTL = 3600; + final public const RETRY_TTL = 3600; /** * Maximum time that the confirmation token will be valid. */ - public const TOKEN_TTL = 43200; + final public const TOKEN_TTL = 43200; /** * @var string @@ -54,7 +55,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFact #[ORM\Column(type: Types::JSON)] private array $roles = []; - #[ORM\OneToMany(mappedBy: 'author', targetEntity: 'App\Entity\Property')] + #[ORM\OneToMany(mappedBy: 'author', targetEntity: Property::class)] private $properties; #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] diff --git a/src/EventSubscriber/ControllerSubscriber.php b/src/EventSubscriber/ControllerSubscriber.php index 0a382b17..2e4ded79 100644 --- a/src/EventSubscriber/ControllerSubscriber.php +++ b/src/EventSubscriber/ControllerSubscriber.php @@ -15,7 +15,7 @@ final class ControllerSubscriber implements EventSubscriberInterface { - public function __construct(private VerifyCsrfToken $verifyCsrfToken, private ThrottleRequests $throttleRequests) + public function __construct(private readonly VerifyCsrfToken $verifyCsrfToken, private readonly ThrottleRequests $throttleRequests) { } diff --git a/src/MessageHandler/DeletePhotosHandler.php b/src/MessageHandler/DeletePhotosHandler.php index ec06bed4..c8dcbbd1 100644 --- a/src/MessageHandler/DeletePhotosHandler.php +++ b/src/MessageHandler/DeletePhotosHandler.php @@ -11,7 +11,7 @@ #[AsMessageHandler] final class DeletePhotosHandler { - public function __construct(private FileUploader $fileUploader) + public function __construct(private readonly FileUploader $fileUploader) { } diff --git a/src/MessageHandler/SendEmailConfirmationLinkHandler.php b/src/MessageHandler/SendEmailConfirmationLinkHandler.php index 784997a9..a1759c21 100644 --- a/src/MessageHandler/SendEmailConfirmationLinkHandler.php +++ b/src/MessageHandler/SendEmailConfirmationLinkHandler.php @@ -21,15 +21,12 @@ final class SendEmailConfirmationLinkHandler { use UserDataCache; - private VerifyEmailHelperInterface $verifyEmailHelper; - public function __construct( - VerifyEmailHelperInterface $helper, - private Mailer $mailer, - private UrlGeneratorInterface $router, - private TranslatorInterface $translator + private readonly VerifyEmailHelperInterface $verifyEmailHelper, + private readonly Mailer $mailer, + private readonly UrlGeneratorInterface $router, + private readonly TranslatorInterface $translator ) { - $this->verifyEmailHelper = $helper; } public function __invoke(SendEmailConfirmationLink $sendEmailConfirmationLink): void diff --git a/src/MessageHandler/SendFeedbackHandler.php b/src/MessageHandler/SendFeedbackHandler.php index 958eb328..9499790f 100644 --- a/src/MessageHandler/SendFeedbackHandler.php +++ b/src/MessageHandler/SendFeedbackHandler.php @@ -15,7 +15,7 @@ #[AsMessageHandler] final class SendFeedbackHandler { - public function __construct(private Mailer $mailer, private TranslatorInterface $translator) + public function __construct(private readonly Mailer $mailer, private readonly TranslatorInterface $translator) { } diff --git a/src/MessageHandler/SendResetPasswordLinkHandler.php b/src/MessageHandler/SendResetPasswordLinkHandler.php index 6bec1a07..f59cc23f 100644 --- a/src/MessageHandler/SendResetPasswordLinkHandler.php +++ b/src/MessageHandler/SendResetPasswordLinkHandler.php @@ -16,8 +16,11 @@ #[AsMessageHandler] final class SendResetPasswordLinkHandler { - public function __construct(private Mailer $mailer, private TranslatorInterface $translator, private UrlGeneratorInterface $router) - { + public function __construct( + private readonly Mailer $mailer, + private readonly TranslatorInterface $translator, + private readonly UrlGeneratorInterface $router + ) { } public function __invoke(SendResetPasswordLink $sendResetPasswordLink): void diff --git a/src/Validator/RegisteredUserValidator.php b/src/Validator/RegisteredUserValidator.php index 4a7e30a3..20ec0ba0 100644 --- a/src/Validator/RegisteredUserValidator.php +++ b/src/Validator/RegisteredUserValidator.php @@ -4,13 +4,14 @@ namespace App\Validator; +use App\Entity\User; use App\Repository\UserRepository; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; final class RegisteredUserValidator extends ConstraintValidator { - public function __construct(private UserRepository $userRepository) + public function __construct(private readonly UserRepository $userRepository) { } @@ -24,7 +25,7 @@ public function validate($value, Constraint $constraint): void $existingUser = $this->userRepository->findOneBy(['email' => $value]); - if (null === $existingUser) { + if (!$existingUser instanceof User) { $this->context->buildViolation($constraint->message) ->addViolation(); } From 66a28bb754bd0058a316e50517e2592f25ab6e05 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 09:43:26 +0300 Subject: [PATCH 4/9] Adjust tags --- src/MessageHandler/SendResetPasswordLinkHandler.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/MessageHandler/SendResetPasswordLinkHandler.php b/src/MessageHandler/SendResetPasswordLinkHandler.php index f59cc23f..2388d29b 100644 --- a/src/MessageHandler/SendResetPasswordLinkHandler.php +++ b/src/MessageHandler/SendResetPasswordLinkHandler.php @@ -8,6 +8,7 @@ use App\Mailer\Mailer; use App\Message\SendResetPasswordLink; use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Address; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -23,12 +24,13 @@ public function __construct( ) { } + /** + * @throws TransportExceptionInterface + */ public function __invoke(SendResetPasswordLink $sendResetPasswordLink): void { - /** @var User $user */ $user = $sendResetPasswordLink->getUser(); - /** @var TemplatedEmail $email */ $email = $this->buildEmail($user); $this->mailer->send($email); From afa84f26c8a452722435156901e6fd529b60166d Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 10:30:54 +0300 Subject: [PATCH 5/9] Create an additional e2e test --- .../security/_google_authenticator.html.twig | 1 + tests/E2E/User/GoogleAuthenticatorTest.php | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/E2E/User/GoogleAuthenticatorTest.php diff --git a/templates/user/security/_google_authenticator.html.twig b/templates/user/security/_google_authenticator.html.twig index e31d2fc5..6acc8233 100644 --- a/templates/user/security/_google_authenticator.html.twig +++ b/templates/user/security/_google_authenticator.html.twig @@ -35,6 +35,7 @@ * login($client, 'user', 'user'); + $client->clickLink('Security'); + $client->waitFor('[data-target="#setUpAuthenticator"]'); + + // Open the modal window + $client->clickLink('Set up Google Authenticator'); + $crawler = $client->waitForVisibility('#generatedSecret'); + $secret = $crawler->filter('#generatedSecret')->text(); + $this->assertSame(52, \strlen($secret)); + + // Enter wrong one time password + $crawler->filter('#generate_google_auth_secret')->form([ + 'authentication_code' => '123456', + ]); + $crawler->filter('#enable2fa')->click(); + $client->waitForVisibility('#twoFactorAuthErrorMessage'); + + $this->assertSame( + 'The Google Authenticator code is incorrect or has expired', + $crawler->filter('#twoFactorAuthErrorMessage')->text() + ); + + // Generate correct one time password + $ga = new \PHPGangsta_GoogleAuthenticator(); + $oneTimePassword = $ga->getCode($secret); + + // Enter correct one time password + $crawler->filter('#generate_google_auth_secret')->form([ + 'authentication_code' => $oneTimePassword, + ]); + $crawler->filter('#enable2fa')->click(); + $client->waitForVisibility('.alert-success'); + } + + /** + * @throws NoSuchElementException + * @throws TimeoutException + */ + public function testDisableAuthenticator(): void + { + $client = self::createPantherClient(); + $client->clickLink('Security'); + + // Open the modal window + $client->clickLink('Set up Google Authenticator'); + $crawler = $client->waitForVisibility('#disable2fa'); + + $crawler->filter('#disable2fa')->click(); + $client->waitForVisibility('.alert-success'); + + // Log Out + $this->logout($client); + $this->assertSelectorTextContains('.h3', 'Popular Listing'); + } +} From e5bd8e86e7c285b2f34c6226ab8393928eda0b4f Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 13:55:19 +0300 Subject: [PATCH 6/9] Update GoogleAuthenticatorTest --- .../auth/two_factor/two_factor_form.html.twig | 2 +- tests/E2E/User/GoogleAuthenticatorTest.php | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/templates/auth/two_factor/two_factor_form.html.twig b/templates/auth/two_factor/two_factor_form.html.twig index da247f5f..72fe1349 100644 --- a/templates/auth/two_factor/two_factor_form.html.twig +++ b/templates/auth/two_factor/two_factor_form.html.twig @@ -11,7 +11,7 @@
{{ '2fa.google_authenticator_code'|trans }}
-
+ diff --git a/tests/E2E/User/GoogleAuthenticatorTest.php b/tests/E2E/User/GoogleAuthenticatorTest.php index 59c8e4a0..6c134614 100644 --- a/tests/E2E/User/GoogleAuthenticatorTest.php +++ b/tests/E2E/User/GoogleAuthenticatorTest.php @@ -15,6 +15,10 @@ final class GoogleAuthenticatorTest extends PantherTestCase use PantherTestHelper; use WebTestHelper; + private const WRONG_ONE_TIME_PASSWORD = '123456'; + + private static string $secret = 'initial'; + /** * @throws NoSuchElementException * @throws TimeoutException @@ -33,9 +37,11 @@ public function testSetUpAuthenticator(): void $secret = $crawler->filter('#generatedSecret')->text(); $this->assertSame(52, \strlen($secret)); + self::$secret = $secret; + // Enter wrong one time password $crawler->filter('#generate_google_auth_secret')->form([ - 'authentication_code' => '123456', + 'authentication_code' => self::WRONG_ONE_TIME_PASSWORD, ]); $crawler->filter('#enable2fa')->click(); $client->waitForVisibility('#twoFactorAuthErrorMessage'); @@ -55,6 +61,10 @@ public function testSetUpAuthenticator(): void ]); $crawler->filter('#enable2fa')->click(); $client->waitForVisibility('.alert-success'); + + // Log Out + $this->logout($client); + $this->assertSelectorTextContains('.h3', 'Popular Listing'); } /** @@ -64,6 +74,42 @@ public function testSetUpAuthenticator(): void public function testDisableAuthenticator(): void { $client = self::createPantherClient(); + + // Try to log in + $client->clickLink('Log in'); + $crawler = $client->waitForVisibility('[name="login_form"]'); + + // Enter credentials + $crawler->filter('[name="login_form"]')->form([ + 'login_form[username]' => 'user', + 'login_form[password]' => 'user', + ]); + $crawler->filter('.btn-primary')->click(); + + // Try wrong one time password + $crawler = $client->waitFor('#_auth_code'); + $crawler->filter('#otp')->form([ + '_auth_code' => self::WRONG_ONE_TIME_PASSWORD, + ]); + $crawler->filter('.btn-primary')->click(); + $this->assertSelectorTextContains('.card-header', 'Google Authenticator code'); + + // Generate correct one time password + $ga = new \PHPGangsta_GoogleAuthenticator(); + $oneTimePassword = $ga->getCode(self::$secret); + + $crawler = $client->waitForVisibility('#otp'); + + // Enter valid one time password + $crawler->filter('#otp')->form([ + '_auth_code' => $oneTimePassword, + ]); + + $crawler->filter('.btn-primary')->click(); + $client->waitFor('h3'); + $this->assertSelectorTextContains('h3', 'My properties'); + + // Go to the Security page and disable Google Authenticator $client->clickLink('Security'); // Open the modal window From c2509cc488a7eaf8678571dbd87875889dc5b609 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 13:58:44 +0300 Subject: [PATCH 7/9] Minor tweak --- config/packages/rate_limiter.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index 3538f207..f9375376 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -2,5 +2,5 @@ framework: rate_limiter: auth: policy: 'fixed_window' - limit: 5 + limit: 6 interval: '100 seconds' From ee67cfd4e8fc2b974e4da597cc3aae6003320264 Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 14:28:52 +0300 Subject: [PATCH 8/9] Fix issues detected by SonarCloud --- .../User/Security/GoogleAuthenticatorController.php | 8 +++++--- src/EventSubscriber/ControllerSubscriber.php | 6 ++++-- tests/E2E/User/GoogleAuthenticatorTest.php | 12 ++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php b/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php index c901628e..f30e665e 100644 --- a/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php +++ b/src/Controller/Ajax/User/Security/GoogleAuthenticatorController.php @@ -15,11 +15,13 @@ final class GoogleAuthenticatorController extends AbstractController implements AjaxController { + private const ENDPOINT = '/user/google_authenticator_code'; + public function __construct(private readonly GoogleAuthenticatorService $service) { } - #[Route(path: '/user/google_authenticator_code', name: 'get_auth_code', methods: ['GET'])] + #[Route(path: self::ENDPOINT, name: 'get_auth_code', methods: ['GET'])] public function getAuthCode(): JsonResponse { try { @@ -34,7 +36,7 @@ public function getAuthCode(): JsonResponse } } - #[Route(path: '/user/google_authenticator_code', name: 'set_auth_code', methods: ['PUT'])] + #[Route(path: self::ENDPOINT, name: 'set_auth_code', methods: ['PUT'])] public function setAuthCode(Request $request): JsonResponse { try { @@ -53,7 +55,7 @@ public function setAuthCode(Request $request): JsonResponse } } - #[Route(path: '/user/google_authenticator_code', name: 'delete_auth_code', methods: ['DELETE'])] + #[Route(path: self::ENDPOINT, name: 'delete_auth_code', methods: ['DELETE'])] public function deleteAuthCode(): JsonResponse { try { diff --git a/src/EventSubscriber/ControllerSubscriber.php b/src/EventSubscriber/ControllerSubscriber.php index 2e4ded79..c1ba9502 100644 --- a/src/EventSubscriber/ControllerSubscriber.php +++ b/src/EventSubscriber/ControllerSubscriber.php @@ -15,8 +15,10 @@ final class ControllerSubscriber implements EventSubscriberInterface { - public function __construct(private readonly VerifyCsrfToken $verifyCsrfToken, private readonly ThrottleRequests $throttleRequests) - { + public function __construct( + private readonly VerifyCsrfToken $verifyCsrfToken, + private readonly ThrottleRequests $throttleRequests + ) { } public function onKernelController(ControllerEvent $event): void diff --git a/tests/E2E/User/GoogleAuthenticatorTest.php b/tests/E2E/User/GoogleAuthenticatorTest.php index 6c134614..c602ca4c 100644 --- a/tests/E2E/User/GoogleAuthenticatorTest.php +++ b/tests/E2E/User/GoogleAuthenticatorTest.php @@ -15,7 +15,7 @@ final class GoogleAuthenticatorTest extends PantherTestCase use PantherTestHelper; use WebTestHelper; - private const WRONG_ONE_TIME_PASSWORD = '123456'; + private const PRIMARY_BUTTON = '.btn-primary'; private static string $secret = 'initial'; @@ -41,7 +41,7 @@ public function testSetUpAuthenticator(): void // Enter wrong one time password $crawler->filter('#generate_google_auth_secret')->form([ - 'authentication_code' => self::WRONG_ONE_TIME_PASSWORD, + 'authentication_code' => random_int(100000, 999999), ]); $crawler->filter('#enable2fa')->click(); $client->waitForVisibility('#twoFactorAuthErrorMessage'); @@ -84,14 +84,14 @@ public function testDisableAuthenticator(): void 'login_form[username]' => 'user', 'login_form[password]' => 'user', ]); - $crawler->filter('.btn-primary')->click(); + $crawler->filter(self::PRIMARY_BUTTON)->click(); // Try wrong one time password $crawler = $client->waitFor('#_auth_code'); $crawler->filter('#otp')->form([ - '_auth_code' => self::WRONG_ONE_TIME_PASSWORD, + '_auth_code' => random_int(100000, 999999), ]); - $crawler->filter('.btn-primary')->click(); + $crawler->filter(self::PRIMARY_BUTTON)->click(); $this->assertSelectorTextContains('.card-header', 'Google Authenticator code'); // Generate correct one time password @@ -105,7 +105,7 @@ public function testDisableAuthenticator(): void '_auth_code' => $oneTimePassword, ]); - $crawler->filter('.btn-primary')->click(); + $crawler->filter(self::PRIMARY_BUTTON)->click(); $client->waitFor('h3'); $this->assertSelectorTextContains('h3', 'My properties'); From 33ca8697eacdc5d9ce108252ddd1a2858f42137f Mon Sep 17 00:00:00 2001 From: Valery Maslov Date: Fri, 5 May 2023 14:36:02 +0300 Subject: [PATCH 9/9] Minor fix --- tests/E2E/User/GoogleAuthenticatorTest.php | 2 +- .../User/Security/GoogleAuthenticatorControllerTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/E2E/User/GoogleAuthenticatorTest.php b/tests/E2E/User/GoogleAuthenticatorTest.php index c602ca4c..80cc86ce 100644 --- a/tests/E2E/User/GoogleAuthenticatorTest.php +++ b/tests/E2E/User/GoogleAuthenticatorTest.php @@ -35,7 +35,7 @@ public function testSetUpAuthenticator(): void $client->clickLink('Set up Google Authenticator'); $crawler = $client->waitForVisibility('#generatedSecret'); $secret = $crawler->filter('#generatedSecret')->text(); - $this->assertSame(52, \strlen($secret)); + $this->assertSame(52, mb_strlen($secret)); self::$secret = $secret; diff --git a/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php b/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php index dddb7bb6..0e76db4b 100644 --- a/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php +++ b/tests/Functional/Controller/User/Security/GoogleAuthenticatorControllerTest.php @@ -45,7 +45,7 @@ public function testGetAuthCode(): void $this->assertResponseIsSuccessful(); $response = $client->getResponse(); $this->assertJson($response->getContent()); - $this->assertTrue(\strlen($response->getContent()) > 2000 && \strlen($response->getContent()) < 3100); + $this->assertTrue(mb_strlen($response->getContent()) > 2000 && mb_strlen($response->getContent()) < 3100); $this->assertContainsWords($response, ['secret', 'qr_code', 'data:image', 'png;base64']); }