diff --git a/composer.json b/composer.json index 14d8f403..4bdfd780 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "symfony/process": "6.4.*", "symfony/property-access": "6.4.*", "symfony/property-info": "6.4.*", + "symfony/rate-limiter": "^6.4", "symfony/runtime": "6.4.*", "symfony/security-bundle": "6.4.*", "symfony/serializer": "6.4.*", diff --git a/composer.lock b/composer.lock index 502d7ba5..8552dd59 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa69dee5968ec6f7c3f152b8a8a6100d", + "content-hash": "1f37ed3314ee4fea04a06dc22f9c2095", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1405,6 +1405,111 @@ ], "time": "2025-10-31T18:51:33+00:00" }, + { + "name": "easycorp/easyadmin-bundle", + "version": "v4.29.12", + "source": { + "type": "git", + "url": "https://github.com/EasyCorp/EasyAdminBundle.git", + "reference": "c6e8111f8215d5ba5ae7c7ec23462d1a56ff41b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/c6e8111f8215d5ba5ae7c7ec23462d1a56ff41b2", + "reference": "c6e8111f8215d5ba5ae7c7ec23462d1a56ff41b2", + "shasum": "" + }, + "require": { + "doctrine/doctrine-bundle": "^2.5|^3.0", + "doctrine/orm": "^2.12|^3.0", + "php": ">=8.1", + "symfony/asset": "^5.4|^6.0|^7.0|^8.0", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/config": "^5.4|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.0", + "symfony/doctrine-bridge": "^5.4|^6.0|^7.0|^8.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/filesystem": "^5.4|^6.0|^7.0|^8.0", + "symfony/form": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0|^8.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0", + "symfony/intl": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/string": "^5.4|^6.0|^7.0|^8.0", + "symfony/translation": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bridge": "^5.4.48|^6.4.16|^7.1.9|^8.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/uid": "^5.4|^6.0|^7.0|^8.0", + "symfony/ux-twig-component": "^2.21", + "symfony/validator": "^5.4|^6.0|^7.0|^8.0", + "twig/extra-bundle": "^3.17", + "twig/html-extra": "^3.17", + "twig/twig": "^3.20" + }, + "conflict": { + "symfony/error-handler": "<5.4.35" + }, + "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^3.4|3.5.x-dev|^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.1|^7.0|^8.0", + "symfony/process": "^5.4|^6.0|^7.0|^8.0", + "symfony/web-link": "^5.4|^6.0|^7.0|^8.0", + "vincentlanglet/twig-cs-fixer": "^3.10" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "EasyCorp\\Bundle\\EasyAdminBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Project Contributors", + "homepage": "https://github.com/EasyCorp/EasyAdminBundle/graphs/contributors" + } + ], + "description": "Admin generator for Symfony applications", + "homepage": "https://github.com/EasyCorp/EasyAdminBundle", + "keywords": [ + "admin", + "backend", + "generator" + ], + "support": { + "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v4.29.12" + }, + "funding": [ + { + "url": "https://github.com/javiereguiluz", + "type": "github" + } + ], + "time": "2026-06-03T19:31:07+00:00" + }, { "name": "egulias/email-validator", "version": "4.0.4", @@ -1668,311 +1773,6 @@ }, "time": "2026-06-01T10:02:14+00:00" }, - { - "name": "knplabs/knp-components", - "version": "v5.2.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/knp-components.git", - "reference": "eabf39263fff305c0024820c3736e5b03e7edf50" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/eabf39263fff305c0024820c3736e5b03e7edf50", - "reference": "eabf39263fff305c0024820c3736e5b03e7edf50", - "shasum": "" - }, - "require": { - "php": "^8.1", - "symfony/event-dispatcher-contracts": "^3.0" - }, - "conflict": { - "doctrine/dbal": "<3.8" - }, - "require-dev": { - "doctrine/dbal": "^3.8 || ^4.0", - "doctrine/mongodb-odm": "^2.5.5", - "doctrine/orm": "^2.13 || ^3.0", - "doctrine/phpcr-odm": "^1.8 || ^2.0", - "ext-pdo_sqlite": "*", - "jackalope/jackalope-doctrine-dbal": "^1.12 || ^2.0", - "phpunit/phpunit": "^10.5 || ^11.3", - "propel/propel1": "^1.7", - "ruflin/elastica": "^7.0", - "solarium/solarium": "^6.0", - "symfony/http-foundation": "^5.4.38 || ^6.4.4 || ^7.0", - "symfony/http-kernel": "^5.4.38 || ^6.4.4 || ^7.0", - "symfony/property-access": "^5.4.38 || ^6.4.4 || ^7.0" - }, - "suggest": { - "doctrine/common": "to allow usage pagination with Doctrine ArrayCollection", - "doctrine/mongodb-odm": "to allow usage pagination with Doctrine ODM MongoDB", - "doctrine/orm": "to allow usage pagination with Doctrine ORM", - "doctrine/phpcr-odm": "to allow usage pagination with Doctrine ODM PHPCR", - "propel/propel1": "to allow usage pagination with Propel ORM", - "ruflin/elastica": "to allow usage pagination with ElasticSearch Client", - "solarium/solarium": "to allow usage pagination with Solarium Client", - "symfony/http-foundation": "to retrieve arguments from Request", - "symfony/property-access": "to allow sorting arrays" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Component\\": "src/Knp/Component" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "KnpLabs Team", - "homepage": "https://knplabs.com" - }, - { - "name": "Symfony Community", - "homepage": "https://github.com/KnpLabs/knp-components/contributors" - } - ], - "description": "Knplabs component library", - "homepage": "https://github.com/KnpLabs/knp-components", - "keywords": [ - "components", - "knp", - "knplabs", - "pager", - "paginator" - ], - "support": { - "issues": "https://github.com/KnpLabs/knp-components/issues", - "source": "https://github.com/KnpLabs/knp-components/tree/v5.2.0" - }, - "time": "2025-03-20T07:35:37+00:00" - }, - { - "name": "knplabs/knp-menu", - "version": "v3.8.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/KnpMenu.git", - "reference": "79d325909a1d428a93f1a0f55e90177830e283bb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/KnpMenu/zipball/79d325909a1d428a93f1a0f55e90177830e283bb", - "reference": "79d325909a1d428a93f1a0f55e90177830e283bb", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "conflict": { - "symfony/http-foundation": "<5.4", - "twig/twig": "<2.16" - }, - "require-dev": { - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.6", - "psr/container": "^1.0 || ^2.0", - "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", - "symfony/phpunit-bridge": "^7.0", - "symfony/routing": "^5.4 || ^6.0 || ^7.0", - "twig/twig": "^2.16 || ^3.0" - }, - "suggest": { - "twig/twig": "for the TwigRenderer and the integration with your templates" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Menu\\": "src/Knp/Menu" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "KnpLabs", - "homepage": "https://knplabs.com" - }, - { - "name": "Christophe Coevoet", - "email": "stof@notk.org" - }, - { - "name": "The Community", - "homepage": "https://github.com/KnpLabs/KnpMenu/contributors" - } - ], - "description": "An object oriented menu library", - "homepage": "https://knplabs.com", - "keywords": [ - "menu", - "tree" - ], - "support": { - "issues": "https://github.com/KnpLabs/KnpMenu/issues", - "source": "https://github.com/KnpLabs/KnpMenu/tree/v3.8.0" - }, - "time": "2025-06-13T15:03:33+00:00" - }, - { - "name": "knplabs/knp-menu-bundle", - "version": "v3.7.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/KnpMenuBundle.git", - "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/KnpMenuBundle/zipball/aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", - "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", - "shasum": "" - }, - "require": { - "knplabs/knp-menu": "^3.8", - "php": "^8.1", - "symfony/config": "^6.4 | ^7.0 | ^8.0", - "symfony/dependency-injection": "^6.4 | ^7.0 | ^8.0", - "symfony/deprecation-contracts": "^2.5 | ^3.3", - "symfony/http-kernel": "^6.4 | ^7.0 | ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5 | ^11.5 | ^12.4", - "symfony/expression-language": "^6.4 | ^7.0 | ^8.0", - "symfony/phpunit-bridge": "^7.0 | ^8.0", - "symfony/templating": "^6.4 | ^7.0 | ^8.0" - }, - "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Bundle\\MenuBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Knplabs", - "homepage": "http://knplabs.com" - }, - { - "name": "Christophe Coevoet", - "email": "stof@notk.org" - }, - { - "name": "Symfony Community", - "homepage": "https://github.com/KnpLabs/KnpMenuBundle/contributors" - } - ], - "description": "This bundle provides an integration of the KnpMenu library", - "keywords": [ - "menu" - ], - "support": { - "issues": "https://github.com/KnpLabs/KnpMenuBundle/issues", - "source": "https://github.com/KnpLabs/KnpMenuBundle/tree/v3.7.0" - }, - "time": "2025-11-30T08:30:04+00:00" - }, - { - "name": "knplabs/knp-paginator-bundle", - "version": "v6.10.0", - "source": { - "type": "git", - "url": "https://github.com/KnpLabs/KnpPaginatorBundle.git", - "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", - "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", - "shasum": "" - }, - "require": { - "knplabs/knp-components": "^4.4 || ^5.0", - "php": "^8.1", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", - "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^10.5 || ^11.5 || ^12.2", - "symfony/templating": "^6.4 || ^7.0 || ^8.0", - "symfony/translation": "^6.4 || ^7.0 || ^8.0", - "twig/twig": "^3.0" - }, - "suggest": { - "symfony/translation": "To use the templates", - "twig/twig": "To use the templates" - }, - "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "6.x-dev" - } - }, - "autoload": { - "psr-4": { - "Knp\\Bundle\\PaginatorBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "KnpLabs Team", - "homepage": "https://knplabs.com" - }, - { - "name": "Symfony Community", - "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle/contributors" - } - ], - "description": "Paginator bundle for Symfony to automate pagination and simplify sorting and other features", - "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle", - "keywords": [ - "bundle", - "knp", - "knplabs", - "pager", - "pagination", - "paginator", - "symfony" - ], - "support": { - "issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues", - "source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.10.0" - }, - "time": "2025-11-29T09:14:09+00:00" - }, { "name": "lcobucci/clock", "version": "3.5.0", @@ -2171,16 +1971,16 @@ }, { "name": "league/flysystem", - "version": "3.35.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "d0a405f03d980461e4b6e08cfed2c162650f9f6b" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/d0a405f03d980461e4b6e08cfed2c162650f9f6b", - "reference": "d0a405f03d980461e4b6e08cfed2c162650f9f6b", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -2248,9 +2048,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-06-22T20:12:06+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-bundle", @@ -2478,27 +2278,27 @@ }, { "name": "league/oauth2-server", - "version": "9.4.0", + "version": "9.3.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-server.git", - "reference": "b96b4a12f87c2cb208cce9436116bc2b7920f796" + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/b96b4a12f87c2cb208cce9436116bc2b7920f796", - "reference": "b96b4a12f87c2cb208cce9436116bc2b7920f796", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb", "shasum": "" }, "require": { "defuse/php-encryption": "^2.4", "ext-json": "*", "ext-openssl": "*", - "lcobucci/clock": "^3.3", - "lcobucci/jwt": "^5.6", + "lcobucci/clock": "^2.3 || ^3.0", + "lcobucci/jwt": "^5.0", "league/event": "^3.0", - "league/uri": "^7.8", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "league/uri": "^7.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/http-message": "^2.0", "psr/http-server-middleware": "^1.0" }, @@ -2507,18 +2307,17 @@ "lncd/oauth2": "*" }, "require-dev": { - "laminas/laminas-diactoros": "^3.8", - "paragonie/random_compat": "^9.99.100", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^11.5.50", + "laminas/laminas-diactoros": "^3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.12|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0", + "phpstan/phpstan-phpunit": "^1.3.15|^2.0", + "phpstan/phpstan-strict-rules": "^1.5.2|^2.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0", "roave/security-advisories": "dev-master", - "slevomat/coding-standard": "^8.27.1", - "squizlabs/php_codesniffer": "^4.0" + "slevomat/coding-standard": "^8.14.1", + "squizlabs/php_codesniffer": "^3.8" }, "type": "library", "autoload": { @@ -2563,7 +2362,7 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-server/issues", - "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.0" + "source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0" }, "funding": [ { @@ -2571,7 +2370,7 @@ "type": "github" } ], - "time": "2026-06-14T08:12:22+00:00" + "time": "2025-11-25T22:51:15+00:00" }, { "name": "league/oauth2-server-bundle", @@ -3271,16 +3070,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.0", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -3372,7 +3171,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nyholm/psr7", @@ -3857,16 +3656,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.53", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "511ddc8e352d5d1f1e33bea468b6f4ef48438cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/511ddc8e352d5d1f1e33bea468b6f4ef48438cf9", + "reference": "511ddc8e352d5d1f1e33bea468b6f4ef48438cf9", "shasum": "" }, "require": { @@ -3947,7 +3746,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.53" }, "funding": [ { @@ -3963,7 +3762,7 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-06-09T18:08:26+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -8727,6 +8526,81 @@ ], "time": "2026-01-02T11:59:06+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v6.4.38", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "d6c01a64fa15d08d42b25af16884fb338b3a2909" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/d6c01a64fa15d08d42b25af16884fb338b3a2909", + "reference": "d6c01a64fa15d08d42b25af16884fb338b3a2909", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/options-resolver": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v6.4.38" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-04T12:52:58+00:00" + }, { "name": "symfony/routing", "version": "v6.4.41", @@ -10144,6 +10018,95 @@ ], "time": "2025-12-23T15:07:59+00:00" }, + { + "name": "symfony/ux-twig-component", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.15|^2.3.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-03T05:13:59+00:00" + }, { "name": "symfony/validator", "version": "v6.4.37", @@ -10859,73 +10822,6 @@ ], "time": "2026-05-19T20:44:48+00:00" }, - { - "name": "twig/string-extra", - "version": "v3.24.0", - "source": { - "type": "git", - "url": "https://github.com/twigphp/string-extra.git", - "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/twigphp/string-extra/zipball/6ec8f2e8ca9b2193221a02cb599dc92c36384368", - "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368", - "shasum": "" - }, - "require": { - "php": ">=8.1.0", - "symfony/string": "^5.4|^6.4|^7.0|^8.0", - "symfony/translation-contracts": "^1.1|^2|^3", - "twig/twig": "^3.13|^4.0" - }, - "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Twig\\Extra\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com", - "homepage": "http://fabien.potencier.org", - "role": "Lead Developer" - } - ], - "description": "A Twig extension for Symfony String", - "homepage": "https://twig.symfony.com", - "keywords": [ - "html", - "string", - "twig", - "unicode" - ], - "support": { - "source": "https://github.com/twigphp/string-extra/tree/v3.24.0" - }, - "funding": [ - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/twig/twig", - "type": "tidelift" - } - ], - "time": "2025-12-02T14:45:16+00:00" - }, { "name": "twig/twig", "version": "v3.27.1", @@ -11008,16 +10904,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.1", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -11068,9 +10964,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-06-15T15:31:57+00:00" + "time": "2026-05-20T13:07:01+00:00" }, { "name": "zenstruck/schedule-bundle", @@ -11597,16 +11493,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.10", + "version": "v3.95.5", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "93e1ab3e1f153024bd3ab23c8a349556063c6f17" + "reference": "7f86d8763063f5d2e2e2d0e1e45bb2f15895361d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/93e1ab3e1f153024bd3ab23c8a349556063c6f17", - "reference": "93e1ab3e1f153024bd3ab23c8a349556063c6f17", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7f86d8763063f5d2e2e2d0e1e45bb2f15895361d", + "reference": "7f86d8763063f5d2e2e2d0e1e45bb2f15895361d", "shasum": "" }, "require": { @@ -11640,16 +11536,16 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.9.0", + "justinrainbow/json-schema": "^6.8.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.38", - "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", - "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + "symfony/polyfill-php85": "^1.37", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -11690,7 +11586,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.10" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.5" }, "funding": [ { @@ -11698,7 +11594,7 @@ "type": "github" } ], - "time": "2026-06-19T14:45:22+00:00" + "time": "2026-06-09T14:55:16+00:00" }, { "name": "myclabs/deep-copy", @@ -12079,16 +11975,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.20", + "version": "2.0.19", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd" + "reference": "546071ed7f80a89ec30909346eb7cc741800740a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd", - "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/546071ed7f80a89ec30909346eb7cc741800740a", + "reference": "546071ed7f80a89ec30909346eb7cc741800740a", "shasum": "" }, "require": { @@ -12147,9 +12043,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.19" }, - "time": "2026-06-16T09:17:35+00:00" + "time": "2026-05-29T12:52:44+00:00" }, { "name": "phpunit/php-code-coverage", @@ -13109,21 +13005,21 @@ }, { "name": "rector/rector", - "version": "2.5.2", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", - "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.2" + "phpstan/phpstan": "^2.1.56" }, "conflict": { "rector/rector-doctrine": "*", @@ -13157,7 +13053,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.5.2" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -13165,7 +13061,7 @@ "type": "github" } ], - "time": "2026-06-22T11:39:33+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 6e7cd0b7..9dd7f6e0 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -4,6 +4,16 @@ framework: annotations: false http_method_override: false handle_all_throwables: true + rate_limiter: + limiters: + registration: + policy: sliding_window + limit: 5 + interval: 15 minutes + password_reset: + policy: sliding_window + limit: 5 + interval: 15 minutes # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. @@ -28,5 +38,7 @@ framework: when@test: framework: test: true + rate_limiter: + enabled: false session: storage_factory_id: session.storage.factory.mock_file diff --git a/config/services.yaml b/config/services.yaml index 28876eed..6a841594 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -136,6 +136,11 @@ services: arguments: $retentionBufferDays: '%travel_snapshot_retention_buffer_days%' + App\Service\PublicFormRateLimiter: + arguments: + $registrationLimiter: '@limiter.registration' + $passwordResetLimiter: '@limiter.password_reset' + App\Service\TravelDataProvider: arguments: $logger: '@monolog.logger.core' diff --git a/config/services_test.yaml b/config/services_test.yaml new file mode 100644 index 00000000..9f47c7d5 --- /dev/null +++ b/config/services_test.yaml @@ -0,0 +1,4 @@ +services: + App\Service\PublicFormRateLimiter: '@App\Tests\Support\TestPublicFormRateLimiter' + + App\Tests\Support\TestPublicFormRateLimiter: ~ diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index e02dec52..6b85dffb 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -106,22 +106,6 @@ class ApiClient return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } - /** - * @throws ApiClientException - */ - public function resetPassword(string $email): Notification - { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey(static::TYPE_CUSTOMER_DATA), - 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], - 'art' => 'Passwort_Anfrage', - 'email' => $email, - ]; - - return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); - } - /** * @throws ApiClientException */ @@ -162,25 +146,6 @@ class ApiClient return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } - /** - * @throws ApiClientException - */ - public function updateNewsletterRegistration(string $email, PersonalData $personalData): Notification|PersonalData - { - $data = array_merge( - $this->buildCustomerDataRequestData($email, 'Newsletter', $personalData->addressId, $personalData->personId), - [ - 'art' => 'Newsletter', - 'newsletter' => [ - 'email' => $email, - 'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False', - ], - ] - ); - - return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); - } - /** * @throws ApiClientException */ diff --git a/src/BusProNet/Model/Communication.php b/src/BusProNet/Model/Communication.php index e211476a..ef66dd39 100644 --- a/src/BusProNet/Model/Communication.php +++ b/src/BusProNet/Model/Communication.php @@ -7,7 +7,7 @@ namespace App\BusProNet\Model; use Symfony\Component\Validator\Constraints as Assert; /** - * Represents communication information including email, phone, and newsletter preferences. + * Represents communication information including email and phone numbers. * * This class handles contact information for users and participants in the booking system. * It provides validation constraints for required fields and methods to convert @@ -24,8 +24,6 @@ class Communication #[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])] public ?string $email = null; - public bool $newsletter = false; - /** * Converts the communication data to API payload format. * @@ -40,7 +38,6 @@ class Communication 'email' => $this->email, 'telefonmobil' => $this->mobile, 'telefonprivat' => $this->phone, - 'newsletter' => $this->newsletter ? 'True' : 'False', ]; } } diff --git a/src/BusProNet/Model/PersonalData.php b/src/BusProNet/Model/PersonalData.php index 4135977c..7d025ed1 100644 --- a/src/BusProNet/Model/PersonalData.php +++ b/src/BusProNet/Model/PersonalData.php @@ -23,10 +23,10 @@ class PersonalData public bool $mutable = false; public ?string $status = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] public ?string $name = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] public ?string $firstName = ''; public ?string $salutation = null; @@ -46,7 +46,7 @@ class PersonalData public ?\DateTimeImmutable $dateOfBirth = null; public ?string $remarks = null; - // Wishes (unterbringungswunsch, beförderungswunsch) + // Wishes (Unterbringungswunsch, Beförderungswunsch) public ?string $remarksRoom = null; public ?string $licensePlate = null; @@ -159,7 +159,6 @@ class PersonalData 'email' => $this->communication->email, 'mobile' => $this->communication->mobile, 'phone' => $this->communication->phone, - 'newsletter' => $this->communication->newsletter, ], 'hotel_codes' => $this->hotelCodes, 'height' => $this->height, diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index 39aabcad..d080f407 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -172,7 +172,6 @@ class BookingParser extends AbstractParser $personalData->communication->phone = $this->getStringOrNullValue($contactNode->filterXPath('//telefonprivat')); $personalData->communication->mobile = $this->getStringOrNullValue($contactNode->filterXPath('//telefonmobil')); $personalData->communication->email = $this->getStringOrNullValue($contactNode->filterXPath('//email')); - $personalData->communication->newsletter = $this->getBoolValue($contactNode->filterXPath('//newsletter')); } // assign default dummy address to ensure valid communication data diff --git a/src/BusProNet/XmlParser/PersonalDataParser.php b/src/BusProNet/XmlParser/PersonalDataParser.php index e9dcb0c6..42856a86 100644 --- a/src/BusProNet/XmlParser/PersonalDataParser.php +++ b/src/BusProNet/XmlParser/PersonalDataParser.php @@ -49,7 +49,6 @@ class PersonalDataParser extends AbstractParser $communication->phone = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonprivat')); $communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil')); $communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email')); - $communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter')); $personalData->communication = $communication; } diff --git a/src/Controller/Account/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php index 2e30b7ee..a22b6f46 100644 --- a/src/Controller/Account/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -15,7 +15,6 @@ use App\Htmx\HxTrait; use App\Model\NewsletterSubscriptionRequestResult; use App\Service\BookingEditDataLoader; use App\Service\NewsletterManager; -use App\Service\ProfileCompletenessChecker; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -23,6 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Controller for managing customer personal data operations. @@ -37,18 +37,10 @@ class PersonalDataController extends AbstractController public const SESSION_REDIRECT_KEY = '_profile_completion_redirect'; - /** - * @param ApiClient $apiClient BusProNet API client for data operations - * @param BookingEditDataLoader $dataLoader Data loader for cache invalidation - * @param ProfileCompletenessChecker $completenessChecker Profile validation service - * @param EntityManagerInterface $entityManager Entity manager for persisting user changes - * @param NewsletterManager $newsletterManager Newsletter confirmation service - * @param LoggerInterface $logger Logger for audit trails and debugging - */ public function __construct( private readonly ApiClient $apiClient, private readonly BookingEditDataLoader $dataLoader, - private readonly ProfileCompletenessChecker $completenessChecker, + private readonly ValidatorInterface $validator, private readonly EntityManagerInterface $entityManager, private readonly NewsletterManager $newsletterManager, private readonly LoggerInterface $logger, @@ -93,7 +85,7 @@ class PersonalDataController extends AbstractController $this->dataLoader->invalidateUserBookingCaches($user); // Update profile completeness flag on user entity - $isComplete = $this->completenessChecker->isComplete($personalData); + $isComplete = 0 === $this->validator->validate($personalData, null, ['personal_data'])->count(); $user->setProfileComplete($isComplete); $this->entityManager->flush(); diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 093f404e..633d625d 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -5,19 +5,33 @@ namespace App\Controller; use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; +use App\BusProNet\Model\RegistrationResponse; +use App\Entity\User; use App\Form\Model\RegistrationDto; use App\Form\RegistrationType; +use App\Repository\UserRepository; +use App\Service\PublicFormRateLimiter; +use App\Service\UserBootstrapper; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; class RegistrationController extends AbstractController { + private const string RESET_PASSWORD_FLASH = 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.'; + public function __construct( private readonly ApiClient $apiClient, + private readonly EntityManagerInterface $entityManager, + private readonly UserRepository $userRepository, + private readonly PublicFormRateLimiter $publicFormRateLimiter, + private readonly UserBootstrapper $userBootstrapper, + private readonly UserPasswordHasherInterface $passwordHasher, private readonly LoggerInterface $logger, ) { } @@ -33,29 +47,75 @@ class RegistrationController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $email = $this->normalizeEmail((string) $registrationData->communication->email); + $registrationData->communication->email = $email; + + if (null !== $this->userRepository->findOneByNormalizedEmail($email)) { + $this->addFlash('error', self::RESET_PASSWORD_FLASH); + + return $this->redirectToRoute('app_reset_password'); + } + + if (false === $this->publicFormRateLimiter->isRegistrationAllowed($email, $request)) { + $this->addFlash('error', PublicFormRateLimiter::THROTTLED_MESSAGE); + + return $this->render('registration/index.html.twig', [ + 'form' => $form, + ], new Response('', Response::HTTP_TOO_MANY_REQUESTS)); + } + try { $response = $this->apiClient->register($registrationData); - if ($response instanceof Notification && true === $response->isError()) { - $this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.'); - } else { - $this->addFlash('success', 'Du erhältst in Kürze eine E-Mail mit einem Link zum (Zurück)setzen deines Passworts.'); + if ($response instanceof Notification) { + $this->addFlash('error', self::RESET_PASSWORD_FLASH); + + return $this->redirectToRoute('app_reset_password'); } + + if (!$response instanceof RegistrationResponse) { + throw new ApiClientException('Die Registrierung konnte nicht vollständig lokal gespeichert werden.'); + } + + $user = new User($email); + $passwordHash = $this->passwordHasher->hashPassword($user, (string) $registrationData->plainPassword); + $this->userBootstrapper->bootstrap( + $user, + $passwordHash, + $response->addressId, + $response->personId, + [], + [], + true, + ); + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $this->addFlash('success', 'Dein Konto wurde erstellt. Du kannst dich jetzt mit deinem Passwort anmelden.'); $this->logger->info('Initiated registration', [ - 'email' => $registrationData->communication->email, + 'email' => $email, ]); + + return $this->redirectToRoute('app_login'); } catch (ApiClientException $e) { $this->addFlash('error', $e->getMessage()); $this->logger->error('Error initiating registration', [ - 'email' => $registrationData->communication->email, + 'email' => $email, 'error' => $e->getMessage(), ]); - } - return $this->redirectToRoute('app_login'); + return $this->render('registration/index.html.twig', [ + 'form' => $form, + ], new Response('', Response::HTTP_BAD_GATEWAY)); + } } return $this->render('registration/index.html.twig', [ 'form' => $form, ]); } + + private function normalizeEmail(string $email): string + { + return mb_strtolower(trim($email)); + } } diff --git a/src/Controller/ResetPasswordController.php b/src/Controller/ResetPasswordController.php index f023263b..6c8bed09 100644 --- a/src/Controller/ResetPasswordController.php +++ b/src/Controller/ResetPasswordController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Service\PasswordResetManager; +use App\Service\PublicFormRateLimiter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; @@ -16,8 +17,10 @@ use Symfony\Component\Validator\Constraints\NotBlank; class ResetPasswordController extends AbstractController { - public function __construct(private readonly PasswordResetManager $passwordResetManager) - { + public function __construct( + private readonly PasswordResetManager $passwordResetManager, + private readonly PublicFormRateLimiter $publicFormRateLimiter, + ) { } #[Route('/reset-password', name: 'app_reset_password')] @@ -45,7 +48,16 @@ class ResetPasswordController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->passwordResetManager->requestPasswordReset($form->get('email')->getData()); + $email = (string) $form->get('email')->getData(); + if (false === $this->publicFormRateLimiter->isPasswordResetAllowed($email, $request)) { + $this->addFlash('error', PublicFormRateLimiter::THROTTLED_MESSAGE); + + return $this->render('reset_password/index.html.twig', [ + 'form' => $form, + ], new Response('', Response::HTTP_TOO_MANY_REQUESTS)); + } + + $this->passwordResetManager->requestPasswordReset($email); $this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts, sofern zu deinem Konto eine E-Mail-Adresse hinterlegt ist.'); return $this->redirectToRoute('app_login'); diff --git a/src/Entity/User.php b/src/Entity/User.php index d7c52428..5b179011 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -50,7 +50,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function __construct(string $email) { - $this->email = $email; + $this->email = self::normalizeEmail($email); } public function getId(): ?int @@ -65,7 +65,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function setEmail(?string $email): static { - $this->email = $email; + $this->email = null === $email ? null : self::normalizeEmail($email); return $this; } @@ -195,4 +195,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { return $this->email; } + + private static function normalizeEmail(string $email): string + { + return mb_strtolower(trim($email)); + } } diff --git a/src/Form/Model/RegistrationDto.php b/src/Form/Model/RegistrationDto.php index 50674407..8164b224 100644 --- a/src/Form/Model/RegistrationDto.php +++ b/src/Form/Model/RegistrationDto.php @@ -26,6 +26,9 @@ class RegistrationDto #[Assert\NotBlank(message: 'Bitte angeben')] public ?string $nationality = null; + #[Assert\NotBlank(message: 'Bitte angeben')] + public ?string $plainPassword = null; + #[Assert\Valid] public Address $address; diff --git a/src/Form/RegistrationType.php b/src/Form/RegistrationType.php index 50f6648f..352f8288 100644 --- a/src/Form/RegistrationType.php +++ b/src/Form/RegistrationType.php @@ -6,6 +6,8 @@ namespace App\Form; use App\Form\Model\RegistrationDto; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -24,6 +26,22 @@ class RegistrationType extends AbstractType 'label' => 'Nachname', 'sanitize_html' => true, ]) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => [ + 'label' => 'Passwort', + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'second_options' => [ + 'label' => 'Passwort wiederholen', + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'invalid_message' => 'Die beiden Passwortangaben müssen identisch sein.', + ]) ->add('personalData', PersonalDataType::class, [ 'label' => false, 'inherit_data' => true, diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index d8d8e710..859758c4 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -12,14 +12,15 @@ use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\PersonalData; use App\Entity\User; use App\Htmx\HxRedirectResponse; -use App\Service\ProfileCompletenessChecker; +use App\Repository\UserRepository; +use App\Service\UserBootstrapper; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; @@ -29,6 +30,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\Util\TargetPathTrait; +use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Authenticates users against the local Symfony password hash. @@ -46,8 +48,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent private readonly ApiClient $apiClient, private readonly EntityManagerInterface $entityManager, private readonly Crypt $crypt, - private readonly ProfileCompletenessChecker $completenessChecker, private readonly UserPasswordHasherInterface $passwordHasher, + private readonly ValidatorInterface $validator, + private readonly UserBootstrapper $userBootstrapper, private readonly LoggerInterface $authLogger, ) { } @@ -62,12 +65,13 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $email = trim($request->request->getString('_username')); $passwordPlain = trim($request->request->getString('_password')); + /** @var UserRepository $userRepository */ $userRepository = $this->entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['email' => $email]); + $user = $userRepository->findOneByNormalizedEmail($email); if (null === $user) { - // First login for this email: prove credentials against BPN, - // then create the local account from the returned customer data. + // Legacy fallback for accounts that were not created locally yet: + // prove credentials against BPN, then bootstrap the local account. $user = $this->bootstrapUser($email, $passwordPlain); } else { if (true === $user->hasLocalPasswordHash()) { @@ -86,6 +90,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $user->setPassword($this->passwordHasher->hashPassword($user, $passwordPlain)); } + + $this->refreshBpnSnapshot($user); } $user->setLastLoginAt(new \DateTimeImmutable()); @@ -114,25 +120,76 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent throw new CustomUserMessageAuthenticationException($message); } - if (false === $personalData instanceof PersonalData || false === $crmAttributes instanceof CrmAttributes) { + if (!$personalData instanceof PersonalData || !$crmAttributes instanceof CrmAttributes) { throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); } $user = new User($email); + $passwordHash = $this->passwordHasher->hashPassword($user, $passwordPlain); + $profileComplete = 0 === $this->validator->validate($personalData, null, ['personal_data'])->count(); + $this->userBootstrapper->bootstrap( + $user, + $passwordHash, + $personalData->addressId, + $personalData->personId, + $crmAttributes->roles, + $crmAttributes->hotelCodes, + $profileComplete, + ); $this->entityManager->persist($user); + return $user; + } + + private function refreshBpnSnapshot(User $user): void + { + try { + $personalData = $this->apiClient->getPersonalData( + $user->getUserIdentifier(), + $user->getAddressId(), + $user->getPersonId(), + ); + } catch (ApiClientException $e) { + $message = $this->isConnectionError($e) + ? 'Der Server ist momentan nicht erreichbar. Bitte versuche es später erneut.' + : $e->getMessage(); + + throw new CustomUserMessageAuthenticationException($message); + } + + if (!$personalData instanceof PersonalData) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + + $profileComplete = 0 === $this->validator->validate($personalData, null, ['personal_data'])->count(); $user - // Persist the local hash immediately so subsequent logins no longer - // depend on the legacy BPN password format. - ->setPassword($this->passwordHasher->hashPassword($user, $passwordPlain)) - ->setPersonId($personalData->personId) ->setAddressId($personalData->addressId) - ->setRoles($crmAttributes->roles) - ->setHotelCodes($crmAttributes->hotelCodes) - ->setProfileComplete($this->completenessChecker->isComplete($personalData)) + ->setPersonId($personalData->personId) + ->setProfileComplete($profileComplete) ; - return $user; + try { + $crmAttributes = $this->apiClient->getCrmAttributes( + $user->getUserIdentifier(), + $user->getAddressId(), + $user->getPersonId(), + ); + } catch (ApiClientException $e) { + $message = $this->isConnectionError($e) + ? 'Der Server ist momentan nicht erreichbar. Bitte versuche es später erneut.' + : $e->getMessage(); + + throw new CustomUserMessageAuthenticationException($message); + } + + if (!$crmAttributes instanceof CrmAttributes) { + throw new CustomUserMessageAuthenticationException('Benutzername oder Passwort ist nicht korrekt.'); + } + + $user + ->setRoles($crmAttributes->roles) + ->setHotelCodes($crmAttributes->hotelCodes) + ; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response diff --git a/src/Service/PasswordResetManager.php b/src/Service/PasswordResetManager.php index ec6fe629..d0a91eea 100644 --- a/src/Service/PasswordResetManager.php +++ b/src/Service/PasswordResetManager.php @@ -8,12 +8,12 @@ use App\Email\Mailer; use App\Entity\User; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\UriSigner; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Psr\Log\LoggerInterface; class PasswordResetManager { diff --git a/src/Service/ProfileCompletenessChecker.php b/src/Service/ProfileCompletenessChecker.php deleted file mode 100644 index 5abe652d..00000000 --- a/src/Service/ProfileCompletenessChecker.php +++ /dev/null @@ -1,47 +0,0 @@ -validator->validate($personalData, null, [self::VALIDATION_GROUP]); - - return 0 === $violations->count(); - } -} diff --git a/src/Service/PublicFormRateLimiter.php b/src/Service/PublicFormRateLimiter.php new file mode 100644 index 00000000..39fd8b84 --- /dev/null +++ b/src/Service/PublicFormRateLimiter.php @@ -0,0 +1,46 @@ +consume($this->registrationLimiter, $email, $request); + } + + public function isPasswordResetAllowed(string $email, Request $request): bool + { + return $this->consume($this->passwordResetLimiter, $email, $request); + } + + public function createBucketKey(string $email, ?string $clientIp): string + { + return hash('sha256', $this->normalizeEmail($email).'|'.($clientIp ?? 'unknown')); + } + + private function consume(RateLimiterFactory $limiterFactory, string $email, Request $request): bool + { + return $limiterFactory->create($this->createBucketKey($email, $request->getClientIp())) + ->consume() + ->isAccepted(); + } + + private function normalizeEmail(string $email): string + { + return mb_strtolower(trim($email)); + } +} diff --git a/src/Service/UserBootstrapper.php b/src/Service/UserBootstrapper.php new file mode 100644 index 00000000..8184e5ad --- /dev/null +++ b/src/Service/UserBootstrapper.php @@ -0,0 +1,35 @@ + $roles + * @param list $hotelCodes + */ + public function bootstrap( + User $user, + string $passwordHash, + ?int $addressId, + ?int $personId, + array $roles, + array $hotelCodes, + bool $profileComplete, + ): User { + $user + ->setPassword($passwordHash) + ->setPersonId($personId) + ->setAddressId($addressId) + ->setRoles($roles) + ->setHotelCodes($hotelCodes) + ->setProfileComplete($profileComplete) + ; + + return $user; + } +} diff --git a/templates/_partials/_header.html.twig b/templates/_partials/_header.html.twig index c177d207..623c2f19 100644 --- a/templates/_partials/_header.html.twig +++ b/templates/_partials/_header.html.twig @@ -1,5 +1,5 @@
- + {% if booking_theme() == 'sbw' %} {% elseif booking_theme() == 'ser' %} diff --git a/templates/registration/index.html.twig b/templates/registration/index.html.twig index 99d91612..c38e2136 100644 --- a/templates/registration/index.html.twig +++ b/templates/registration/index.html.twig @@ -34,6 +34,11 @@ {{ form_row(form.personalData.postCode, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.personalData.city, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.personalData.country, { 'label_attr': { 'class': 'text-white' } }) }} +

+ Passwort +

+ {{ form_row(form.plainPassword.first, { 'label_attr': { 'class': 'text-white' } }) }} + {{ form_row(form.plainPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
diff --git a/tests/BusProNet/Model/PersonalDataCompletenessTest.php b/tests/BusProNet/Model/PersonalDataCompletenessTest.php new file mode 100644 index 00000000..451cc896 --- /dev/null +++ b/tests/BusProNet/Model/PersonalDataCompletenessTest.php @@ -0,0 +1,118 @@ +createCompletePersonalData(); + $personalData->remarks = 'Optional note'; + $personalData->address->district = 'Mitte'; + $personalData->communication->phone = '0301234567'; + + $violations = $this->validator()->validate($personalData, null, ['personal_data']); + + self::assertCount(0, $violations); + } + + public function testMissingRequiredPersonalDataFieldFailsPersonalDataValidationGroup(): void + { + $personalData = $this->createCompletePersonalData(); + $personalData->name = null; + + $violations = $this->validator()->validate($personalData, null, ['personal_data']); + + self::assertCount(1, $violations); + self::assertSame('name', $violations->get(0)->getPropertyPath()); + } + + public function testAddressCompletenessIgnoresOptionalDistrict(): void + { + $address = new Address(); + $address->street = 'Main Street 1'; + $address->postCode = '12345'; + $address->city = 'Berlin'; + $address->country = 'DE'; + $address->district = 'Mitte'; + + $violations = $this->validator()->validate($address, null, ['personal_data']); + + self::assertCount(0, $violations); + + $address->street = null; + + $violations = $this->validator()->validate($address, null, ['personal_data']); + + self::assertCount(1, $violations); + self::assertSame('street', $violations->get(0)->getPropertyPath()); + } + + public function testCommunicationCompletenessIgnoresOptionalPhone(): void + { + $communication = new Communication(); + $communication->email = 'customer@example.com'; + $communication->mobile = '0123456789'; + $communication->phone = '0301234567'; + + $violations = $this->validator()->validate($communication, null, ['personal_data']); + + self::assertCount(0, $violations); + + $communication->mobile = null; + + $violations = $this->validator()->validate($communication, null, ['personal_data']); + + self::assertCount(1, $violations); + self::assertSame('mobile', $violations->get(0)->getPropertyPath()); + } + + private function createCompletePersonalData(): PersonalData + { + $personalData = new PersonalData(); + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + $personalData->communication->email = 'customer@example.com'; + $personalData->communication->mobile = '0123456789'; + + return $personalData; + } + + private function validator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new class extends ConstraintValidatorFactory { + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + if (true === ($constraint instanceof Email)) { + return new EmailValidator(Email::VALIDATION_MODE_HTML5); + } + + return parent::getInstance($constraint); + } + }) + ->getValidator(); + } +} diff --git a/tests/Controller/Account/PersonalDataControllerTest.php b/tests/Controller/Account/PersonalDataControllerTest.php index f5017630..5d087b7d 100644 --- a/tests/Controller/Account/PersonalDataControllerTest.php +++ b/tests/Controller/Account/PersonalDataControllerTest.php @@ -11,15 +11,23 @@ use App\Entity\User; use App\Model\NewsletterSubscriptionRequestResult; use App\Service\BookingEditDataLoader; use App\Service\NewsletterManager; -use App\Service\ProfileCompletenessChecker; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidatorFactory; +use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\EmailValidator; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\Validator\ValidatorInterface; class PersonalDataControllerTest extends TestCase { @@ -149,8 +157,55 @@ class PersonalDataControllerTest extends TestCase ], $controller->flashes); } - private function createController(): TestablePersonalDataController + public function testIndexRecomputesProfileCompletionAfterSavingPersonalData(): void { + $personalData = $this->createCompletePersonalData(); + $controller = $this->createController(true, true, $this->validator()); + + $controller->apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', null, null) + ->willReturn($personalData); + $controller->apiClient + ->expects(self::once()) + ->method('updatePersonalData') + ->with('customer@example.com', $personalData) + ->willReturn($personalData); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasConfirmedOptIn') + ->with('customer@example.com') + ->willReturn(false); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->willReturn(false); + $controller->dataLoader + ->expects(self::once()) + ->method('invalidateUserBookingCaches') + ->with($controller->user); + $controller->entityManager + ->expects(self::once()) + ->method('flush'); + + $request = Request::create('/personal-data', 'POST'); + $request->setSession(new Session(new MockArraySessionStorage())); + $response = $controller->index($request); + + self::assertSame(302, $response->getStatusCode()); + self::assertTrue($controller->user->isProfileComplete()); + self::assertSame([ + ['success', 'Deine persönlichen Daten wurden aktualisiert'], + ], $controller->flashes); + } + + private function createController( + bool $submitted = false, + bool $valid = false, + ?ValidatorInterface $validator = null, + ): TestablePersonalDataController { $user = new User('customer@example.com'); $user->setPassword('secret'); @@ -162,12 +217,17 @@ class PersonalDataControllerTest extends TestCase ->willReturnSelf(); $form ->method('isSubmitted') - ->willReturn(false); + ->willReturn($submitted); + $form + ->method('isValid') + ->willReturn($valid); + + $validator ??= $this->createMock(ValidatorInterface::class); return new TestablePersonalDataController( $apiClient, $this->createMock(BookingEditDataLoader::class), - $this->createMock(ProfileCompletenessChecker::class), + $validator, $this->createMock(EntityManagerInterface::class), $this->createMock(NewsletterManager::class), $this->createMock(LoggerInterface::class), @@ -184,6 +244,41 @@ class PersonalDataControllerTest extends TestCase return $personalData; } + + private function createCompletePersonalData(): PersonalData + { + $personalData = new PersonalData(); + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + $personalData->communication->email = 'customer@example.com'; + $personalData->communication->mobile = '0123456789'; + + return $personalData; + } + + private function validator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new class extends ConstraintValidatorFactory { + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + if (true === ($constraint instanceof Email)) { + return new EmailValidator(Email::VALIDATION_MODE_HTML5); + } + + return parent::getInstance($constraint); + } + }) + ->getValidator(); + } } final class TestablePersonalDataController extends PersonalDataController @@ -202,18 +297,18 @@ final class TestablePersonalDataController extends PersonalDataController public function __construct( public readonly ApiClient $apiClient, - BookingEditDataLoader $dataLoader, - ProfileCompletenessChecker $completenessChecker, - EntityManagerInterface $entityManager, + public readonly BookingEditDataLoader $dataLoader, + public readonly ValidatorInterface $validator, + public readonly EntityManagerInterface $entityManager, public readonly NewsletterManager $newsletterManager, LoggerInterface $logger, - private readonly User $user, + public readonly User $user, private readonly FormInterface $form, ) { parent::__construct( $apiClient, $dataLoader, - $completenessChecker, + $validator, $entityManager, $newsletterManager, $logger, diff --git a/tests/Controller/RegistrationControllerTest.php b/tests/Controller/RegistrationControllerTest.php new file mode 100644 index 00000000..5ee52800 --- /dev/null +++ b/tests/Controller/RegistrationControllerTest.php @@ -0,0 +1,353 @@ + 'on', + ]); + $client->disableReboot(); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->willReturn(null); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter + ->expects(self::once()) + ->method('isRegistrationAllowed') + ->with('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(false); + + $apiClient = $this->createMock(ApiClient::class); + $this->configureBaseData($apiClient); + $apiClient->expects(self::never())->method('register'); + $apiClient->expects(self::never())->method('getCrmAttributesWithPassword'); + $apiClient->expects(self::never())->method('getPersonalDataWithPassword'); + + self::getContainer()->set(UserRepository::class, $userRepository); + self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter); + self::getContainer()->set(ApiClient::class, $apiClient); + + $crawler = $client->request('GET', '/registration'); + $form = $crawler->selectButton('Jetzt registrieren')->form([ + 'registration[firstName]' => 'Customer', + 'registration[name]' => 'Example', + 'registration[plainPassword][first]' => 'secret-password', + 'registration[plainPassword][second]' => 'secret-password', + 'registration[personalData][gender]' => 'M', + 'registration[personalData][dateOfBirth][day]' => '1', + 'registration[personalData][dateOfBirth][month]' => '1', + 'registration[personalData][dateOfBirth][year]' => '1990', + 'registration[personalData][street]' => 'Main Street 1', + 'registration[personalData][postCode]' => '12345', + 'registration[personalData][city]' => 'Berlin', + 'registration[personalData][country]' => 'DE', + 'registration[personalData][nationality]' => 'DE', + 'registration[personalData][email]' => 'Customer@Example.COM', + 'registration[personalData][mobile]' => '0123456789', + ]); + $client->submit($form); + + self::assertResponseStatusCodeSame(429); + self::assertStringContainsString(PublicFormRateLimiter::THROTTLED_MESSAGE, $client->getResponse()->getContent()); + } + + public function testDuplicateLocalUserRedirectsToResetPassword(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $existingUser = new User('customer@example.com'); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->willReturn($existingUser); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter->expects(self::never())->method('isRegistrationAllowed'); + + $apiClient = $this->createMock(ApiClient::class); + $this->configureBaseData($apiClient); + $apiClient->expects(self::never())->method('register'); + $apiClient->expects(self::never())->method('getCrmAttributesWithPassword'); + $apiClient->expects(self::never())->method('getPersonalDataWithPassword'); + + self::getContainer()->set(UserRepository::class, $userRepository); + self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter); + self::getContainer()->set(ApiClient::class, $apiClient); + + $crawler = $client->request('GET', '/registration'); + $form = $crawler->selectButton('Jetzt registrieren')->form([ + 'registration[firstName]' => 'Customer', + 'registration[name]' => 'Example', + 'registration[plainPassword][first]' => 'secret-password', + 'registration[plainPassword][second]' => 'secret-password', + 'registration[personalData][gender]' => 'M', + 'registration[personalData][dateOfBirth][day]' => '1', + 'registration[personalData][dateOfBirth][month]' => '1', + 'registration[personalData][dateOfBirth][year]' => '1990', + 'registration[personalData][street]' => 'Main Street 1', + 'registration[personalData][postCode]' => '12345', + 'registration[personalData][city]' => 'Berlin', + 'registration[personalData][country]' => 'DE', + 'registration[personalData][nationality]' => 'DE', + 'registration[personalData][email]' => 'Customer@Example.COM', + 'registration[personalData][mobile]' => '0123456789', + ]); + $client->submit($form); + + self::assertResponseRedirects('/reset-password'); + } + + public function testIncompleteRegistrationSubmissionIsRejectedByValidation(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository->expects(self::never())->method('findOneByNormalizedEmail'); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter->expects(self::never())->method('isRegistrationAllowed'); + + $apiClient = $this->createMock(ApiClient::class); + $this->configureBaseData($apiClient); + $apiClient->expects(self::never())->method('register'); + $apiClient->expects(self::never())->method('getCrmAttributesWithPassword'); + $apiClient->expects(self::never())->method('getPersonalDataWithPassword'); + + self::getContainer()->set(UserRepository::class, $userRepository); + self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter); + self::getContainer()->set(ApiClient::class, $apiClient); + + $crawler = $client->request('GET', '/registration'); + $form = $crawler->selectButton('Jetzt registrieren')->form([ + 'registration[firstName]' => 'Customer', + 'registration[name]' => 'Example', + 'registration[plainPassword][first]' => 'secret-password', + 'registration[plainPassword][second]' => 'secret-password', + 'registration[personalData][gender]' => 'M', + 'registration[personalData][dateOfBirth][day]' => '1', + 'registration[personalData][dateOfBirth][month]' => '1', + 'registration[personalData][dateOfBirth][year]' => '1990', + 'registration[personalData][street]' => 'Main Street 1', + 'registration[personalData][postCode]' => '12345', + 'registration[personalData][city]' => 'Berlin', + 'registration[personalData][country]' => 'DE', + 'registration[personalData][nationality]' => 'DE', + 'registration[personalData][email]' => 'Customer@Example.COM', + ]); + $client->submit($form); + + self::assertResponseStatusCodeSame(422); + } + + public function testSuccessfulRegistrationDoesNotRefetchPersonalData(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->willReturn(null); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter + ->expects(self::once()) + ->method('isRegistrationAllowed') + ->with('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(true); + + $apiClient = $this->createMock(ApiClient::class); + $this->configureBaseData($apiClient); + $apiClient + ->expects(self::once()) + ->method('register') + ->with(self::callback(function ($registrationData): bool { + self::assertSame('customer@example.com', $registrationData->communication->email); + + return true; + })) + ->willReturn(new RegistrationResponse(123, 456, true)); + $apiClient + ->expects(self::never()) + ->method('getPersonalDataWithPassword'); + $apiClient + ->expects(self::never()) + ->method('getCrmAttributesWithPassword'); + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::once()) + ->method('hashPassword') + ->with(self::isInstanceOf(User::class), 'secret-password') + ->willReturn('hashed-secret'); + + $persistedUser = null; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (User $user) use (&$persistedUser): bool { + $persistedUser = $user; + + return true; + })); + $entityManager + ->expects(self::once()) + ->method('flush'); + + self::getContainer()->set(UserRepository::class, $userRepository); + self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter); + self::getContainer()->set(ApiClient::class, $apiClient); + self::getContainer()->set(UserPasswordHasherInterface::class, $passwordHasher); + self::getContainer()->set(EntityManagerInterface::class, $entityManager); + + $crawler = $client->request('GET', '/registration'); + $form = $crawler->selectButton('Jetzt registrieren')->form([ + 'registration[firstName]' => 'Customer', + 'registration[name]' => 'Example', + 'registration[plainPassword][first]' => 'secret-password', + 'registration[plainPassword][second]' => 'secret-password', + 'registration[personalData][gender]' => 'M', + 'registration[personalData][dateOfBirth][day]' => '1', + 'registration[personalData][dateOfBirth][month]' => '1', + 'registration[personalData][dateOfBirth][year]' => '1990', + 'registration[personalData][street]' => 'Main Street 1', + 'registration[personalData][postCode]' => '12345', + 'registration[personalData][city]' => 'Berlin', + 'registration[personalData][country]' => 'DE', + 'registration[personalData][nationality]' => 'DE', + 'registration[personalData][email]' => 'Customer@Example.COM', + 'registration[personalData][mobile]' => '0123456789', + ]); + $client->submit($form); + + self::assertResponseRedirects('/'); + self::assertInstanceOf(User::class, $persistedUser); + self::assertSame('customer@example.com', $persistedUser->getEmail()); + self::assertSame('hashed-secret', $persistedUser->getPassword()); + self::assertSame(123, $persistedUser->getAddressId()); + self::assertSame(456, $persistedUser->getPersonId()); + self::assertSame(['ROLE_USER'], $persistedUser->getRoles()); + self::assertSame([], $persistedUser->getHotelCodes()); + self::assertTrue($persistedUser->isProfileComplete()); + } + + public function testRegistrationApiFailureRendersFormInsteadOfResetPassword(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->willReturn(null); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter + ->expects(self::once()) + ->method('isRegistrationAllowed') + ->with('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(true); + + $apiClient = $this->createMock(ApiClient::class); + $this->configureBaseData($apiClient); + $apiClient + ->expects(self::once()) + ->method('register') + ->willThrowException(new ApiClientException('Registrierung momentan nicht möglich.')); + $apiClient + ->expects(self::never()) + ->method('getCrmAttributesWithPassword'); + $apiClient + ->expects(self::never()) + ->method('getPersonalDataWithPassword'); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::never()) + ->method('persist'); + $entityManager + ->expects(self::never()) + ->method('flush'); + + self::getContainer()->set(UserRepository::class, $userRepository); + self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter); + self::getContainer()->set(ApiClient::class, $apiClient); + self::getContainer()->set(EntityManagerInterface::class, $entityManager); + + $crawler = $client->request('GET', '/registration'); + $form = $crawler->selectButton('Jetzt registrieren')->form([ + 'registration[firstName]' => 'Customer', + 'registration[name]' => 'Example', + 'registration[plainPassword][first]' => 'secret-password', + 'registration[plainPassword][second]' => 'secret-password', + 'registration[personalData][gender]' => 'M', + 'registration[personalData][dateOfBirth][day]' => '1', + 'registration[personalData][dateOfBirth][month]' => '1', + 'registration[personalData][dateOfBirth][year]' => '1990', + 'registration[personalData][street]' => 'Main Street 1', + 'registration[personalData][postCode]' => '12345', + 'registration[personalData][city]' => 'Berlin', + 'registration[personalData][country]' => 'DE', + 'registration[personalData][nationality]' => 'DE', + 'registration[personalData][email]' => 'Customer@Example.COM', + 'registration[personalData][mobile]' => '0123456789', + ]); + $client->submit($form); + + self::assertResponseStatusCodeSame(502); + self::assertStringContainsString('Registrierung momentan nicht möglich.', $client->getResponse()->getContent()); + } + + private function configureBaseData(ApiClient $apiClient): void + { + $country = new Country(); + $country->token = 'DE'; + $country->name = 'Germany'; + $country->nationality = 'DE'; + + $apiClient + ->method('getBaseData') + ->willReturn(new BaseData(['DE' => $country])); + } +} diff --git a/tests/Controller/ResetPasswordControllerTest.php b/tests/Controller/ResetPasswordControllerTest.php index 00aa1b94..1f03fa68 100644 --- a/tests/Controller/ResetPasswordControllerTest.php +++ b/tests/Controller/ResetPasswordControllerTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Controller; use App\Entity\User; use App\Service\PasswordResetManager; +use App\Service\PublicFormRateLimiter; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; @@ -65,6 +66,44 @@ class ResetPasswordControllerTest extends WebTestCase self::assertStringContainsString('Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts', $client->getResponse()->getContent()); } + public function testPasswordResetRequestReturns429WhenThrottled(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $email = sprintf('throttle-%s@example.com', bin2hex(random_bytes(4))); + + $manager = $this->createMock(PasswordResetManager::class); + $manager + ->expects(self::exactly(5)) + ->method('requestPasswordReset') + ->with($email) + ->willReturn(true); + self::getContainer()->set(PasswordResetManager::class, $manager); + + for ($i = 0; $i < 5; ++$i) { + $crawler = $client->request('GET', '/reset-password'); + $form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([ + 'form[email]' => $email, + ]); + $client->submit($form); + + self::assertResponseRedirects('/'); + $client->followRedirect(); + } + + $crawler = $client->request('GET', '/reset-password'); + $form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([ + 'form[email]' => $email, + ]); + $client->submit($form); + + self::assertResponseStatusCodeSame(429); + self::assertStringContainsString(PublicFormRateLimiter::THROTTLED_MESSAGE, $client->getResponse()->getContent()); + } + public function testPasswordResetConfirmationDisplaysFormAndUpdatesPassword(): void { $client = self::createClient([], [ diff --git a/tests/Security/BpnAuthenticatorTest.php b/tests/Security/BpnAuthenticatorTest.php index 0815b8cd..3c425967 100644 --- a/tests/Security/BpnAuthenticatorTest.php +++ b/tests/Security/BpnAuthenticatorTest.php @@ -5,26 +5,35 @@ declare(strict_types=1); namespace App\Tests\Security; use App\BusProNet\ApiClient; +use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\PersonalData; use App\Entity\User; +use App\Repository\UserRepository; use App\Security\BpnAuthenticator; use App\Security\Crypt; +use App\Service\UserBootstrapper; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -use App\Service\ProfileCompletenessChecker; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; class BpnAuthenticatorTest extends TestCase { public function testAuthenticateAcceptsLocalPasswordHashWithoutLegacyDecryption(): void { $user = new User('customer@example.com'); + $user + ->setAddressId(10) + ->setPersonId(20) + ->setProfileComplete(false) + ; $user->setPassword(password_hash('secret', PASSWORD_BCRYPT)); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); @@ -34,14 +43,52 @@ class BpnAuthenticatorTest extends TestCase ->with($user, 'secret') ->willReturn(true); + $personalData = new PersonalData(); + $personalData->addressId = 123; + $personalData->personId = 456; + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->communication->email = 'customer@example.com'; + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', 10, 20) + ->willReturn($personalData); + $apiClient + ->expects(self::once()) + ->method('getCrmAttributes') + ->with('customer@example.com', 123, 456) + ->willReturn((function (): CrmAttributes { + $crmAttributes = new CrmAttributes(); + $crmAttributes->roles = ['ROLE_ADMIN']; + $crmAttributes->hotelCodes = ['AAA']; + + return $crmAttributes; + })()); + $authenticator = $this->createAuthenticator( user: $user, + apiClient: $apiClient, passwordHasher: $passwordHasher, ); $passport = $authenticator->authenticate($this->createRequest('customer@example.com', 'secret')); self::assertInstanceOf(SelfValidatingPassport::class, $passport); + self::assertSame(123, $user->getAddressId()); + self::assertSame(456, $user->getPersonId()); + self::assertTrue($user->isProfileComplete()); + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); + self::assertSame(['AAA'], $user->getHotelCodes()); } public function testAuthenticateMigratesLegacyPasswordToLocalHashOnSuccess(): void @@ -66,8 +113,41 @@ class BpnAuthenticatorTest extends TestCase ->with($user, 'secret') ->willReturn('local-hash'); + $personalData = new PersonalData(); + $personalData->addressId = 123; + $personalData->personId = 456; + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->communication->email = 'customer@example.com'; + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', null, null) + ->willReturn($personalData); + $apiClient + ->expects(self::once()) + ->method('getCrmAttributes') + ->with('customer@example.com', 123, 456) + ->willReturn((function (): CrmAttributes { + $crmAttributes = new CrmAttributes(); + $crmAttributes->roles = ['ROLE_CUSTOMER']; + $crmAttributes->hotelCodes = []; + + return $crmAttributes; + })()); + $authenticator = $this->createAuthenticator( user: $user, + apiClient: $apiClient, crypt: $crypt, passwordHasher: $passwordHasher, ); @@ -76,6 +156,70 @@ class BpnAuthenticatorTest extends TestCase self::assertInstanceOf(SelfValidatingPassport::class, $passport); self::assertSame('local-hash', $user->getPassword()); + self::assertSame(123, $user->getAddressId()); + self::assertSame(456, $user->getPersonId()); + } + + public function testAuthenticateFailsClosedWhenCrmSyncFailsAfterLocalPasswordValidation(): void + { + $user = new User('customer@example.com'); + $user + ->setPassword(password_hash('secret', PASSWORD_BCRYPT)) + ->setRoles(['ROLE_ADMIN']) + ->setHotelCodes(['STALE']) + ; + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::once()) + ->method('isPasswordValid') + ->with($user, 'secret') + ->willReturn(true); + + $personalData = new PersonalData(); + $personalData->addressId = 123; + $personalData->personId = 456; + $personalData->firstName = 'Mia'; + $personalData->name = 'Muster'; + $personalData->gender = 'W'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->communication->email = 'customer@example.com'; + $personalData->address->street = 'Main Street 1'; + $personalData->address->postCode = '10115'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', null, null) + ->willReturn($personalData); + $apiClient + ->expects(self::once()) + ->method('getCrmAttributes') + ->with('customer@example.com', 123, 456) + ->willThrowException(new TimeoutException()); + + $validator = $this->createMock(ValidatorInterface::class); + $validator + ->expects(self::once()) + ->method('validate') + ->with($personalData, null, ['personal_data']) + ->willReturn(new ConstraintViolationList()); + + $authenticator = $this->createAuthenticator( + user: $user, + apiClient: $apiClient, + passwordHasher: $passwordHasher, + validator: $validator, + expectFlush: false, + ); + + $this->expectException(CustomUserMessageAuthenticationException::class); + + $authenticator->authenticate($this->createRequest('customer@example.com', 'secret')); } public function testAuthenticateBootstrapsMissingUserFromBpn(): void @@ -119,19 +263,20 @@ class BpnAuthenticatorTest extends TestCase ->with(self::isInstanceOf(User::class), $password) ->willReturn('local-hash'); - $completenessChecker = $this->createMock(ProfileCompletenessChecker::class); - $completenessChecker + $validator = $this->createMock(ValidatorInterface::class); + $validator ->expects(self::once()) - ->method('isComplete') - ->with($personalData) - ->willReturn(true); + ->method('validate') + ->with($personalData, null, ['personal_data']) + ->willReturn(new ConstraintViolationList()); + $userBootstrapper = new UserBootstrapper(); $persistedUser = null; - $repository = $this->createMock(EntityRepository::class); + $repository = $this->createMock(UserRepository::class); $repository ->expects(self::once()) - ->method('findOneBy') - ->with(['email' => $email]) + ->method('findOneByNormalizedEmail') + ->with($email) ->willReturn(null); $entityManager = $this->createMock(EntityManagerInterface::class); @@ -155,8 +300,9 @@ class BpnAuthenticatorTest extends TestCase $apiClient, $entityManager, $this->createMock(Crypt::class), - $completenessChecker, $passwordHasher, + $validator, + $userBootstrapper, $this->createMock(LoggerInterface::class), ); @@ -172,19 +318,108 @@ class BpnAuthenticatorTest extends TestCase self::assertTrue($persistedUser->isProfileComplete()); } + public function testAuthenticatePreservesNullBpnIdsWhenBootstrappingMissingUser(): void + { + $email = 'customer@example.com'; + $password = 'secret'; + + $personalData = new PersonalData(); + $personalData->addressId = null; + $personalData->personId = null; + + $crmAttributes = new CrmAttributes(); + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects(self::once()) + ->method('getPersonalDataWithPassword') + ->with($email, $password) + ->willReturn($personalData); + $apiClient->expects(self::once()) + ->method('getCrmAttributesWithPassword') + ->with($email, $password) + ->willReturn($crmAttributes); + + $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); + $passwordHasher + ->expects(self::once()) + ->method('hashPassword') + ->with(self::isInstanceOf(User::class), $password) + ->willReturn('local-hash'); + + $validator = $this->createMock(ValidatorInterface::class); + $validator + ->expects(self::once()) + ->method('validate') + ->with($personalData, null, ['personal_data']) + ->willReturn(new ConstraintViolationList()); + + $persistedUser = null; + $repository = $this->createMock(UserRepository::class); + $repository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with($email) + ->willReturn(null); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->method('getRepository') + ->willReturn($repository); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (User $user) use (&$persistedUser): bool { + $persistedUser = $user; + + return true; + })); + $entityManager + ->expects(self::once()) + ->method('flush'); + + $authenticator = new BpnAuthenticator( + $this->createMock(UrlGeneratorInterface::class), + $apiClient, + $entityManager, + $this->createMock(Crypt::class), + $passwordHasher, + $validator, + new UserBootstrapper(), + $this->createMock(LoggerInterface::class), + ); + + $authenticator->authenticate($this->createRequest($email, $password)); + + self::assertInstanceOf(User::class, $persistedUser); + self::assertNull($persistedUser->getAddressId()); + self::assertNull($persistedUser->getPersonId()); + } + private function createAuthenticator( ?User $user = null, ?ApiClient $apiClient = null, ?Crypt $crypt = null, - ?ProfileCompletenessChecker $completenessChecker = null, ?UserPasswordHasherInterface $passwordHasher = null, + ?ValidatorInterface $validator = null, + ?UserBootstrapper $userBootstrapper = null, + bool $expectFlush = true, ): BpnAuthenticator { - $repository = $this->createMock(EntityRepository::class); + $validatorProvided = null !== $validator; + $passwordHasher ??= $this->createMock(UserPasswordHasherInterface::class); + $validator ??= $this->createMock(ValidatorInterface::class); + if (!$validatorProvided) { + $validator + ->method('validate') + ->willReturn(new ConstraintViolationList()); + } + $userBootstrapper ??= new UserBootstrapper(); + + $repository = $this->createMock(UserRepository::class); $repository ->expects(self::once()) - ->method('findOneBy') - ->with(self::callback(static function (array $criteria): bool { - return isset($criteria['email']) && is_string($criteria['email']); + ->method('findOneByNormalizedEmail') + ->with(self::callback(static function (string $email): bool { + return '' !== trim($email); })) ->willReturn($user); @@ -194,7 +429,7 @@ class BpnAuthenticatorTest extends TestCase ->with(User::class) ->willReturn($repository); $entityManager - ->expects(self::once()) + ->expects($expectFlush ? self::once() : self::never()) ->method('flush'); $entityManager ->expects(null === $user ? self::once() : self::never()) @@ -205,8 +440,9 @@ class BpnAuthenticatorTest extends TestCase $apiClient ?? $this->createMock(ApiClient::class), $entityManager, $crypt ?? $this->createMock(Crypt::class), - $completenessChecker ?? $this->createMock(ProfileCompletenessChecker::class), - $passwordHasher ?? $this->createMock(UserPasswordHasherInterface::class), + $passwordHasher, + $validator, + $userBootstrapper, $this->createMock(LoggerInterface::class), ); } diff --git a/tests/Support/TestPublicFormRateLimiter.php b/tests/Support/TestPublicFormRateLimiter.php new file mode 100644 index 00000000..5e4863bc --- /dev/null +++ b/tests/Support/TestPublicFormRateLimiter.php @@ -0,0 +1,44 @@ + + */ + private array $counters = []; + + public function __construct() + { + } + + public function isRegistrationAllowed(string $email, Request $request): bool + { + return $this->consume($email, $request); + } + + public function isPasswordResetAllowed(string $email, Request $request): bool + { + return $this->consume($email, $request); + } + + public function createBucketKey(string $email, ?string $clientIp): string + { + return hash('sha256', mb_strtolower(trim($email)).'|'.($clientIp ?? 'unknown')); + } + + private function consume(string $email, Request $request): bool + { + $bucketKey = $this->createBucketKey($email, $request->getClientIp()); + $current = $this->counters[$bucketKey] ?? 0; + $this->counters[$bucketKey] = ++$current; + + return 5 >= $current; + } +}