├── .markdownlint.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Resources └── config │ └── routing.xml ├── composer.json ├── config ├── api_platform.xml ├── doctrine │ └── AbstractPasswordToken.orm.xml └── services.xml ├── phpunit-legacy.xml.dist ├── rector.php └── src ├── Bridge └── ApiPlatform │ ├── OpenApi │ ├── AbstractOpenApiFactory.php │ └── OpenApiFactory.php │ └── Serializer │ └── DocumentationNormalizer.php ├── Controller ├── ForgotPasswordController.php ├── GetToken.php ├── ResetPassword.php └── UpdatePassword.php ├── CoopTilleulsForgotPasswordBundle.php ├── DependencyInjection ├── BCExtensionTrait.php ├── CompilerPass │ └── ApiPlatformCompilerPass.php ├── Configuration.php ├── CoopTilleulsForgotPasswordExtension.php └── CoopTilleulsForgotPasswordLegacyExtension.php ├── Entity └── AbstractPasswordToken.php ├── Event ├── CreateTokenEvent.php ├── ForgotPasswordEvent.php ├── PolyfillEvent.php ├── UpdatePasswordEvent.php └── UserNotFoundEvent.php ├── EventListener ├── ExceptionEventListener.php ├── MainRequestTrait.php └── RequestEventListener.php ├── Exception ├── InvalidJsonHttpException.php ├── JsonHttpExceptionInterface.php ├── MissingFieldHttpException.php ├── NoParameterException.php ├── UnauthorizedFieldException.php └── UndefinedProviderException.php ├── Manager ├── Bridge │ ├── DoctrineManager.php │ └── ManagerInterface.php ├── ForgotPasswordManager.php └── PasswordTokenManager.php ├── Normalizer ├── JMSNormalizer.php ├── NormalizerInterface.php └── SymfonyNormalizer.php ├── Provider ├── Provider.php ├── ProviderChain.php ├── ProviderChainInterface.php └── ProviderInterface.php ├── Routing └── RouteLoader.php └── TokenGenerator ├── Bridge └── Bin2HexTokenGenerator.php └── TokenGeneratorInterface.php /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": { 3 | "line_length": 120, 4 | "code_block_line_length": 200 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 6 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 7 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, 8 | religion, or sexual identity and orientation. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | * Using welcoming and inclusive language 15 | * Being respectful of differing viewpoints and experiences 16 | * Gracefully accepting constructive criticism 17 | * Focusing on what is best for the community 18 | * Showing empathy towards other community members 19 | 20 | Examples of unacceptable behavior by participants include: 21 | 22 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 23 | * Trolling, insulting/derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 26 | * Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Our Responsibilities 29 | 30 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take 31 | appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 34 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 35 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 40 | project or its community. Examples of representing a project or community include using an official project e-mail 41 | address, posting via an official social media account, or acting as an appointed representative at an online or offline 42 | event. Representation of a project may be further defined and clarified by project maintainers. 43 | 44 | ## Enforcement 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at 47 | gregoire+abuse@les-tilleuls.coop We will review and investigate all complaints, and will respond in a way that it deems 48 | appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter 49 | of an incident. Further details of specific enforcement policies may be posted separately. 50 | 51 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent 52 | repercussions as determined by other members of the project's leadership. 53 | 54 | ## Attribution 55 | 56 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available 57 | at [http://contributor-covenant.org/version/1/4][version] 58 | 59 | [homepage]: http://contributor-covenant.org 60 | 61 | [version]: http://contributor-covenant.org/version/1/4/ 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to CoopTilleulsForgotPasswordBundle 2 | 3 | First of all, thank you for contributing, you're awesome! 4 | 5 | To have your code integrated in the CoopTilleulsForgotPasswordBundle project, there is some rules to follow, but don't 6 | panic, it's easy! 7 | 8 | ## Reporting bugs 9 | 10 | If you happen to find a bug, we kindly request you to report it using GitHub by following these 3 points: 11 | 12 | * Check if the bug is not already reported 13 | * A clear title to resume the issue 14 | * A description of the workflow needed to reproduce the bug 15 | 16 | > _NOTE:_ Don't hesitate giving as much information as you can (OS, PHP version, extensions...) 17 | 18 | ## Pull Requests 19 | 20 | ### Matching coding standards 21 | 22 | The CoopTilleulsForgotPasswordBundle project follows [Symfony coding standards](https://symfony.com/doc/current/contributing/code/standards.html). 23 | But don't worry, you can fix CS issues automatically using the [PHP CS Fixer](http://cs.sensiolabs.org/) tool. Run phpcs fixer: 24 | 25 | ```bash 26 | vendor/bin/php-cs-fixer fix 27 | ``` 28 | 29 | And then, add fixed file to your commit before push. Be sure to add only **your modified files**. If another files are 30 | fixed by cs tools, just revert it before commit. 31 | 32 | ### Sending a Pull Request 33 | 34 | When you send a PR, just make sure that: 35 | 36 | * You add valid test cases (Behat and PHPUnit) 37 | * Tests are green 38 | * You add some documentation (PHPDoc & user doc: README, custom documentation file) 39 | * You make the PR on the same branch you based your changes on. If you see commits that you did not make in your PR, 40 | you're doing it wrong 41 | * Also don't forget to add a comment when you update a PR with a ping to the maintainer (`@vincentchalamon`), 42 | so we will get a notification 43 | * [Squash your commits](#squash-your-commits) into one commit 44 | 45 | All Pull Requests must include [this header](.github/PULL_REQUEST_TEMPLATE.md). 46 | 47 | ## Squash your commits 48 | 49 | If you have 3 commits. So start with: 50 | 51 | ```bash 52 | git rebase -i HEAD~3 53 | ``` 54 | 55 | An editor will be opened with your 3 commits, all prefixed by `pick`. 56 | 57 | Replace all `pick` prefixes by `fixup` (or `f`) **except the first commit** of the list. 58 | 59 | Save and quit the editor. 60 | 61 | After that, all your commits where squashed into the first one and the commit message of the first commit. 62 | 63 | If you would like to rename your commit message type: 64 | 65 | ```bash 66 | git commit --amend 67 | ``` 68 | 69 | Now force push to update your PR: 70 | 71 | ```bash 72 | git push --force 73 | ``` 74 | 75 | # License and copyright attribution 76 | 77 | When you open a Pull Request to the CoopTilleulsForgotPasswordBundle project, you agree to license your code under the 78 | [MIT license](LICENSE) and to transfer the copyright on the submitted code to Vincent CHALAMON. 79 | 80 | Be sure to you have the right to do that (if you are a professional, ask your company)! 81 | 82 | If you include code from another project, please mention it in the Pull Request description and credit the original 83 | author. 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2016 La Coopérative des Tilleuls 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoopTilleulsForgotPasswordBundle 2 | 3 | This Symfony bundle provides a _forgot password_ feature for a REST API. 4 | It is bridged for [API Platform](https://api-platform.com/). 5 | 6 | [![Actions Status](https://github.com/coopTilleuls/CoopTilleulsForgotPasswordBundle/workflows/CI/badge.svg)](https://github.com/coopTilleuls/CoopTilleulsForgotPasswordBundle/actions) 7 | [![Packagist Version](https://img.shields.io/packagist/v/tilleuls/forgot-password-bundle.svg?style=flat-square)](https://packagist.org/packages/tilleuls/forgot-password-bundle) 8 | [![Software license](https://img.shields.io/github/license/coopTilleuls/CoopTilleulsForgotPasswordBundle.svg?style=flat-square)](https://github.com/coopTilleuls/CoopTilleulsForgotPasswordBundle/blob/main/LICENSE) 9 | 10 | ## Installation 11 | 12 | [Read the installation guide](docs/index.md). 13 | 14 | ## Contributing 15 | 16 | [Read the contributing guide](CONTRIBUTING.md). 17 | 18 | ## Backward Compatibility Promise 19 | 20 | This project follows the [Semantic Versioning](https://semver.org/), and the Backward Compatibility promise from 21 | the [Symfony framework](https://symfony.com/doc/current/contributing/code/bc.html). 22 | 23 | ## Credits 24 | 25 | Created by [Vincent CHALAMON](https://github.com/vincentchalamon) for [Les-Tilleuls.coop](https://les-tilleuls.coop/). 26 | -------------------------------------------------------------------------------- /Resources/config/routing.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tilleuls/forgot-password-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Provides a 'forgot password' feature for a REST API", 5 | "keywords": [ 6 | "Forgot password", 7 | "REST", 8 | "API" 9 | ], 10 | "license": "MIT", 11 | "homepage": "https://github.com/coopTilleuls/CoopTilleulsForgotPasswordBundle", 12 | "authors": [ 13 | { 14 | "name": "Vincent CHALAMON", 15 | "email": "vincent@les-tilleuls.coop" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "symfony/config": "^5.1 || ^6.0 || ^7.0", 21 | "symfony/dependency-injection": "^5.1 || ^6.0 || ^7.0", 22 | "symfony/event-dispatcher": "^5.1 || ^6.0 || ^7.0", 23 | "symfony/http-foundation": "^5.1 || ^6.0 || ^7.0", 24 | "symfony/http-kernel": "^5.1.5 || ^6.0 || ^7.0", 25 | "symfony/serializer": "^5.1 || ^6.0 || ^7.0" 26 | }, 27 | "require-dev": { 28 | "ext-json": "*", 29 | "behat/behat": "^3.1", 30 | "dg/bypass-finals": "^1.1", 31 | "doctrine/data-fixtures": "^1.2 || ^2.0", 32 | "doctrine/doctrine-bundle": "^2.11", 33 | "doctrine/orm": "^2.6.3 || ^3.0", 34 | "friends-of-behat/symfony-extension": "^2.0.11 || ^2.1.0", 35 | "jms/serializer-bundle": "^1.4 || ^2.3 || ^3.0 || ^4.0 || ^5.0", 36 | "laminas/laminas-code": "^3.4 || ^4.0", 37 | "ocramius/proxy-manager": "^2.0.4", 38 | "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", 39 | "symfony/asset": "^5.1 || ^6.0 || ^7.0", 40 | "symfony/browser-kit": "^5.1 || ^6.0 || ^7.0", 41 | "symfony/framework-bundle": "^5.1 || ^6.0 || ^7.0", 42 | "symfony/mailer": "^5.1 || ^6.0 || ^7.0", 43 | "symfony/property-access": "^5.1 || ^6.0 || ^7.0", 44 | "symfony/security-bundle": "^5.1 || ^6.0 || ^7.0", 45 | "symfony/stopwatch": "^5.1 || ^6.0 || ^7.0", 46 | "symfony/templating": "^5.1 || ^6.0 || ^7.0", 47 | "symfony/twig-bundle": "^5.1 || ^6.0 || ^7.0", 48 | "symfony/var-dumper": "^5.1 || ^6.0 || ^7.0" 49 | }, 50 | "suggest": { 51 | "doctrine/doctrine-bundle": "To connect with Doctrine in Symfony project", 52 | "doctrine/orm": "To connect with Doctrine", 53 | "api-platform/openapi": "To connect with API Platform" 54 | }, 55 | "autoload": { 56 | "psr-4": { 57 | "CoopTilleuls\\ForgotPasswordBundle\\": "src/" 58 | } 59 | }, 60 | "autoload-dev": { 61 | "psr-4": { 62 | "CoopTilleuls\\ForgotPasswordBundle\\Tests\\": "tests", 63 | "App\\": "features/app/src" 64 | } 65 | }, 66 | "config": { 67 | "sort-packages": true, 68 | "allow-plugins": { 69 | "composer/package-versions-deprecated": true 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/api_platform.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /config/doctrine/AbstractPasswordToken.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /phpunit-legacy.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 27 | 28 | src 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | use Rector\Config\RectorConfig; 15 | use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector; 16 | 17 | return RectorConfig::configure() 18 | ->withPaths([ 19 | __DIR__.'/features', 20 | __DIR__.'/src', 21 | __DIR__.'/tests', 22 | ]) 23 | // uncomment to reach your current PHP version 24 | // ->withPhpSets() 25 | ->withRules([ 26 | AddVoidReturnTypeWhereNoReturnRector::class, 27 | ]) 28 | ->withPhpSets(php81: true); 29 | -------------------------------------------------------------------------------- /src/Bridge/ApiPlatform/OpenApi/AbstractOpenApiFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Bridge\ApiPlatform\OpenApi; 15 | 16 | use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface as LegacyOpenApiFactoryInterface; 17 | use ApiPlatform\Core\OpenApi\Model\Operation as LegacyOperation; 18 | use ApiPlatform\Core\OpenApi\Model\PathItem as LegacyPathItem; 19 | use ApiPlatform\Core\OpenApi\Model\RequestBody as LegacyRequestBody; 20 | use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; 21 | use ApiPlatform\OpenApi\Model\Operation; 22 | use ApiPlatform\OpenApi\Model\PathItem; 23 | use ApiPlatform\OpenApi\Model\RequestBody; 24 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderChainInterface; 25 | use Symfony\Component\Routing\RouterInterface; 26 | 27 | /** 28 | * @author Vincent CHALAMON 29 | */ 30 | abstract class AbstractOpenApiFactory 31 | { 32 | public function __construct(protected readonly LegacyOpenApiFactoryInterface|OpenApiFactoryInterface $decorated, protected readonly RouterInterface $router, protected readonly ProviderChainInterface $providerChain) 33 | { 34 | } 35 | 36 | public function __invoke(array $context = []) 37 | { 38 | $routes = $this->router->getRouteCollection(); 39 | $openApi = ($this->decorated)($context); 40 | $schemas = $openApi->getComponents()->getSchemas(); 41 | $paths = $openApi->getPaths(); 42 | 43 | $resetProperties = []; 44 | $requestProperties = []; 45 | foreach ($this->providerChain->all() as $provider) { 46 | $userPasswordField = $provider->getUserPasswordField(); 47 | if (!\array_key_exists($userPasswordField, $resetProperties)) { 48 | $resetProperties[$userPasswordField] = [ 49 | 'type' => 'object', 50 | 'required' => [$userPasswordField], 51 | 'properties' => [ 52 | $userPasswordField => ['type' => 'string'], 53 | ], 54 | ]; 55 | } 56 | 57 | $userAuthorizedFields = $provider->getUserAuthorizedFields(); 58 | foreach ($userAuthorizedFields as $userAuthorizedField) { 59 | if (!\array_key_exists($userAuthorizedField, $requestProperties)) { 60 | $requestProperties[$userAuthorizedField] = [ 61 | 'type' => 'object', 62 | 'required' => [$userAuthorizedField], 63 | 'properties' => [ 64 | $userAuthorizedField => [ 65 | 'type' => ['string', 'integer'], 66 | ], 67 | ], 68 | ]; 69 | } 70 | } 71 | } 72 | $resetSchema = 1 < \count($resetProperties) ? ['oneOf' => array_values($resetProperties)] : array_values($resetProperties)[0]; 73 | $requestSchema = 1 < \count($requestProperties) ? ['oneOf' => array_values($requestProperties)] : array_values($requestProperties)[0]; 74 | 75 | $schemas['ForgotPassword:reset'] = new \ArrayObject($resetSchema); 76 | 77 | $schemas['ForgotPassword:request'] = new \ArrayObject($requestSchema); 78 | 79 | $schemas['ForgotPassword:validate'] = new \ArrayObject([ 80 | 'type' => ['object', 'null'], 81 | ]); 82 | 83 | $resetForgotPasswordPath = $routes->get('coop_tilleuls_forgot_password.reset')->getPath(); 84 | $paths->addPath($resetForgotPasswordPath, ($paths->getPath($resetForgotPasswordPath) ?: (class_exists(PathItem::class) ? new PathItem() : new LegacyPathItem())) 85 | ->withRef('ForgotPassword') 86 | ->withPost((class_exists(Operation::class) ? new Operation() : new LegacyOperation()) 87 | ->withOperationId('postForgotPassword') 88 | ->withTags(['Forgot password']) 89 | ->withResponses([ 90 | 204 => [ 91 | 'description' => 'Valid email address, no matter if user exists or not', 92 | ], 93 | 422 => [ 94 | 'description' => 'Missing email parameter or invalid format', 95 | ], 96 | ]) 97 | ->withSummary('Generates a token and send email') 98 | ->withParameters([ 99 | [ 100 | 'name' => 'FP-provider', 101 | 'in' => 'header', 102 | 'required' => false, 103 | 'schema' => [ 104 | 'type' => 'string', 105 | ], 106 | ], 107 | ]) 108 | ->withRequestBody((class_exists(RequestBody::class) ? new RequestBody() : new LegacyRequestBody()) 109 | ->withDescription('Request a new password') 110 | ->withRequired(true) 111 | ->withContent(new \ArrayObject([ 112 | 'application/json' => [ 113 | 'schema' => [ 114 | '$ref' => '#/components/schemas/ForgotPassword:request', 115 | ], 116 | ], 117 | ]) 118 | ) 119 | ) 120 | ) 121 | ); 122 | 123 | $getForgotPasswordPath = $routes->get('coop_tilleuls_forgot_password.get_token')->getPath(); 124 | $paths->addPath($getForgotPasswordPath, ($paths->getPath($getForgotPasswordPath) ?: (class_exists(PathItem::class) ? new PathItem() : new LegacyPathItem())) 125 | ->withRef('ForgotPassword') 126 | ->withGet((class_exists(Operation::class) ? new Operation() : new LegacyOperation()) 127 | ->withOperationId('getForgotPassword') 128 | ->withTags(['Forgot password']) 129 | ->withResponses([ 130 | 200 => [ 131 | 'description' => 'Authenticated user', 132 | 'content' => [ 133 | 'application/json' => [ 134 | 'schema' => [ 135 | '$ref' => '#/components/schemas/ForgotPassword:validate', 136 | ], 137 | ], 138 | ], 139 | ], 140 | 404 => [ 141 | 'description' => 'Token not found or expired', 142 | ], 143 | ]) 144 | ->withSummary('Validates token') 145 | ->withParameters([ 146 | [ 147 | 'name' => 'tokenValue', 148 | 'in' => 'path', 149 | 'required' => true, 150 | 'schema' => [ 151 | 'type' => 'string', 152 | ], 153 | ], 154 | [ 155 | 'name' => 'FP-provider', 156 | 'in' => 'header', 157 | 'required' => false, 158 | 'schema' => [ 159 | 'type' => 'string', 160 | ], 161 | ], 162 | ], 163 | ) 164 | ) 165 | ); 166 | 167 | $updateForgotPasswordPath = $routes->get('coop_tilleuls_forgot_password.update')->getPath(); 168 | $paths->addPath($updateForgotPasswordPath, ($paths->getPath($updateForgotPasswordPath) ?: (class_exists(PathItem::class) ? new PathItem() : new LegacyPathItem())) 169 | ->withRef('ForgotPassword') 170 | ->withPost((class_exists(Operation::class) ? new Operation() : new LegacyOperation()) 171 | ->withOperationId('postForgotPasswordToken') 172 | ->withTags(['Forgot password']) 173 | ->withResponses([ 174 | 204 => [ 175 | 'description' => 'Email address format valid, no matter if user exists or not', 176 | ], 177 | 422 => [ 178 | 'description' => 'Missing password parameter', 179 | ], 180 | 404 => [ 181 | 'description' => 'Token not found', 182 | ], 183 | ]) 184 | ->withSummary('Validates token') 185 | ->withParameters([ 186 | [ 187 | 'name' => 'tokenValue', 188 | 'in' => 'path', 189 | 'required' => true, 190 | 'schema' => [ 191 | 'type' => 'string', 192 | ], 193 | ], 194 | [ 195 | 'name' => 'FP-provider', 196 | 'in' => 'header', 197 | 'required' => false, 198 | 'schema' => [ 199 | 'type' => 'string', 200 | ], 201 | ], 202 | ]) 203 | ->withRequestBody((class_exists(RequestBody::class) ? new RequestBody() : new LegacyRequestBody()) 204 | ->withDescription('Reset password') 205 | ->withRequired(true) 206 | ->withContent(new \ArrayObject([ 207 | 'application/json' => [ 208 | 'schema' => [ 209 | '$ref' => '#/components/schemas/ForgotPassword:reset', 210 | ], 211 | ], 212 | ]) 213 | ) 214 | ) 215 | ) 216 | ); 217 | 218 | return $openApi; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Bridge/ApiPlatform/OpenApi/OpenApiFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Bridge\ApiPlatform\OpenApi; 15 | 16 | use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface as LegacyOpenApiFactoryInterface; 17 | use ApiPlatform\Core\OpenApi\OpenApi as LegacyOpenApi; 18 | use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; 19 | use ApiPlatform\OpenApi\OpenApi; 20 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderChainInterface; 21 | use Symfony\Component\Routing\RouterInterface; 22 | 23 | if (interface_exists(OpenApiFactoryInterface::class)) { 24 | final class OpenApiFactory extends AbstractOpenApiFactory implements OpenApiFactoryInterface 25 | { 26 | public function __construct(OpenApiFactoryInterface $decorated, RouterInterface $router, ProviderChainInterface $providerChain) 27 | { 28 | parent::__construct($decorated, $router, $providerChain); 29 | } 30 | 31 | public function __invoke(array $context = []): OpenApi 32 | { 33 | return parent::__invoke($context); 34 | } 35 | } 36 | } else { 37 | final class OpenApiFactory extends AbstractOpenApiFactory implements LegacyOpenApiFactoryInterface 38 | { 39 | public function __construct(LegacyOpenApiFactoryInterface $decorated, RouterInterface $router, ProviderChainInterface $providerChain) 40 | { 41 | parent::__construct($decorated, $router, $providerChain); 42 | } 43 | 44 | public function __invoke(array $context = []): LegacyOpenApi 45 | { 46 | return parent::__invoke($context); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Bridge/ApiPlatform/Serializer/DocumentationNormalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Bridge\ApiPlatform\Serializer; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderChainInterface; 17 | use Symfony\Component\Routing\RouterInterface; 18 | use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; 19 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 20 | 21 | /** 22 | * @author Vincent CHALAMON 23 | */ 24 | final class DocumentationNormalizer implements NormalizerInterface 25 | { 26 | public function __construct(private readonly NormalizerInterface $decorated, private readonly RouterInterface $router, private readonly ProviderChainInterface $providerChain) 27 | { 28 | } 29 | 30 | public function normalize($object, $format = null, array $context = []): array 31 | { 32 | $routes = $this->router->getRouteCollection(); 33 | $docs = $this->decorated->normalize($object, $format, $context); 34 | 35 | $resetProperties = []; 36 | $requestProperties = []; 37 | foreach ($this->providerChain->all() as $provider) { 38 | $userPasswordField = $provider->getUserPasswordField(); 39 | if (!\array_key_exists($userPasswordField, $resetProperties)) { 40 | $resetProperties[$userPasswordField] = [ 41 | 'type' => 'object', 42 | 'required' => [$userPasswordField], 43 | 'properties' => [ 44 | $userPasswordField => ['type' => 'string'], 45 | ], 46 | ]; 47 | } 48 | 49 | $userAuthorizedFields = $provider->getUserAuthorizedFields(); 50 | foreach ($userAuthorizedFields as $userAuthorizedField) { 51 | if (!\array_key_exists($userAuthorizedField, $requestProperties)) { 52 | $requestProperties[$userAuthorizedField] = [ 53 | 'type' => 'object', 54 | 'required' => [$userAuthorizedField], 55 | 'properties' => [ 56 | $userAuthorizedField => [ 57 | 'type' => ['string', 'integer'], 58 | ], 59 | ], 60 | ]; 61 | } 62 | } 63 | } 64 | $resetSchema = 1 < \count($resetProperties) ? ['oneOf' => array_values($resetProperties)] : array_values($resetProperties)[0]; 65 | $requestSchema = 1 < \count($requestProperties) ? ['oneOf' => array_values($requestProperties)] : array_values($requestProperties)[0]; 66 | 67 | // Add POST /forgot-password/ path 68 | $docs['tags'][] = ['name' => 'Forgot password']; 69 | $docs['paths'][$routes->get('coop_tilleuls_forgot_password.reset')->getPath()]['post'] = [ 70 | 'tags' => ['Forgot password'], 71 | 'operationId' => 'postForgotPassword', 72 | 'summary' => 'Generates a token and send email', 73 | 'responses' => [ 74 | 204 => [ 75 | 'description' => 'Valid email address, no matter if user exists or not', 76 | ], 77 | 422 => [ 78 | 'description' => 'Missing email parameter or invalid format', 79 | ], 80 | ], 81 | 'requestBody' => [ 82 | 'description' => 'Request a new password', 83 | 'content' => [ 84 | 'application/json' => [ 85 | 'schema' => [ 86 | '$ref' => '#/components/schemas/ForgotPassword:request', 87 | ], 88 | ], 89 | ], 90 | ], 91 | ]; 92 | $docs['components']['schemas']['ForgotPassword:request'] = array_merge([ 93 | 'description' => 'New password request object', 94 | ], $requestSchema); 95 | 96 | // Add GET /forgot-password/{tokenValue} path 97 | $docs['paths'][$routes->get('coop_tilleuls_forgot_password.get_token')->getPath()]['get'] = [ 98 | 'tags' => ['Forgot password'], 99 | 'operationId' => 'getForgotPassword', 100 | 'summary' => 'Validates token', 101 | 'responses' => [ 102 | 200 => [ 103 | 'description' => 'Authenticated user', 104 | 'content' => [ 105 | 'application/json' => [ 106 | 'schema' => [ 107 | '$ref' => '#/components/schemas/ForgotPassword:validate', 108 | ], 109 | ], 110 | ], 111 | ], 112 | 404 => [ 113 | 'description' => 'Token not found or expired', 114 | ], 115 | ], 116 | 'parameters' => [ 117 | [ 118 | 'name' => 'tokenValue', 119 | 'in' => 'path', 120 | 'required' => true, 121 | 'schema' => [ 122 | 'type' => 'string', 123 | ], 124 | ], 125 | [ 126 | 'name' => 'FP-provider', 127 | 'in' => 'headers', 128 | 'required' => false, 129 | 'schema' => [ 130 | 'type' => 'string', 131 | ], 132 | ], 133 | ], 134 | ]; 135 | $docs['components']['schemas']['ForgotPassword:validate'] = [ 136 | 'type' => ['object', 'null'], 137 | 'description' => 'Authenticated user', 138 | ]; 139 | 140 | // Add POST /forgot-password/{tokenValue} path 141 | $docs['paths'][$routes->get('coop_tilleuls_forgot_password.update')->getPath()]['post'] = [ 142 | 'tags' => ['Forgot password'], 143 | 'operationId' => 'postForgotPasswordToken', 144 | 'summary' => 'Resets user password from token', 145 | 'responses' => [ 146 | 204 => [ 147 | 'description' => 'Email address format valid, no matter if user exists or not', 148 | ], 149 | 422 => [ 150 | 'description' => 'Missing password parameter', 151 | ], 152 | 404 => [ 153 | 'description' => 'Token not found', 154 | ], 155 | ], 156 | 'parameters' => [ 157 | [ 158 | 'name' => 'tokenValue', 159 | 'in' => 'path', 160 | 'required' => true, 161 | 'schema' => [ 162 | 'type' => 'string', 163 | ], 164 | ], 165 | ], 166 | 'requestBody' => [ 167 | 'description' => 'Reset password', 168 | 'content' => [ 169 | 'application/json' => [ 170 | 'schema' => [ 171 | '$ref' => '#/components/schemas/ForgotPassword:reset', 172 | ], 173 | ], 174 | ], 175 | ], 176 | ]; 177 | $docs['components']['schemas']['ForgotPassword:reset'] = array_merge([ 178 | 'description' => 'Reset password object', 179 | ], $resetSchema); 180 | 181 | return $docs; 182 | } 183 | 184 | public function supportsNormalization($data, $format = null, array $context = []): bool 185 | { 186 | return $this->decorated->supportsNormalization($data, $format); 187 | } 188 | 189 | public function getSupportedTypes(?string $format): array 190 | { 191 | // @deprecated remove condition when support for symfony versions under 6.4 is dropped 192 | if (!method_exists($this->decorated, 'getSupportedTypes')) { 193 | return [ 194 | '*' => $this->decorated instanceof CacheableSupportsMethodInterface && $this->decorated->hasCacheableSupportsMethod(), 195 | ]; 196 | } 197 | 198 | return $this->decorated->getSupportedTypes($format); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Controller/ForgotPasswordController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Controller; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 18 | use Symfony\Component\HttpFoundation\JsonResponse; 19 | use Symfony\Component\HttpFoundation\Response; 20 | 21 | /** 22 | * @author Vincent CHALAMON 23 | * 24 | * @deprecated Use invokable controllers instead 25 | */ 26 | final class ForgotPasswordController 27 | { 28 | public function __construct(private readonly GetToken $getToken, private readonly UpdatePassword $updatePassword, private readonly ResetPassword $resetPassword) 29 | { 30 | } 31 | 32 | /** 33 | * @param string $propertyName 34 | * @param string $value 35 | * 36 | * @return Response 37 | */ 38 | public function resetPasswordAction($propertyName, $value, ProviderInterface $provider) 39 | { 40 | $resetPassword = $this->resetPassword; 41 | 42 | return $resetPassword($propertyName, $value, $provider); 43 | } 44 | 45 | /** 46 | * @return JsonResponse 47 | */ 48 | public function getTokenAction(AbstractPasswordToken $token, ProviderInterface $provider) 49 | { 50 | $getToken = $this->getToken; 51 | 52 | return $getToken($token, $provider); 53 | } 54 | 55 | /** 56 | * @param string $password 57 | * 58 | * @return Response 59 | */ 60 | public function updatePasswordAction(AbstractPasswordToken $token, $password, ProviderInterface $provider) 61 | { 62 | $updatePassword = $this->updatePassword; 63 | 64 | return $updatePassword($token, $password, $provider); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Controller/GetToken.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Controller; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use CoopTilleuls\ForgotPasswordBundle\Normalizer\NormalizerInterface; 18 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 19 | use Symfony\Component\HttpFoundation\JsonResponse; 20 | 21 | /** 22 | * @author Vincent CHALAMON 23 | */ 24 | final class GetToken 25 | { 26 | public function __construct(private readonly NormalizerInterface $normalizer) 27 | { 28 | } 29 | 30 | /** 31 | * @return JsonResponse 32 | */ 33 | public function __invoke(AbstractPasswordToken $token, ProviderInterface $provider) 34 | { 35 | $groups = $provider->getPasswordTokenSerializationGroups(); 36 | 37 | return new JsonResponse( 38 | $this->normalizer->normalize($token, 'json', $groups ? ['groups' => $groups] : []) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Controller/ResetPassword.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Controller; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Manager\ForgotPasswordManager; 17 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 18 | use Symfony\Component\HttpFoundation\Response; 19 | 20 | /** 21 | * @author Vincent CHALAMON 22 | */ 23 | final class ResetPassword 24 | { 25 | public function __construct(private readonly ForgotPasswordManager $forgotPasswordManager) 26 | { 27 | } 28 | 29 | /** 30 | * @param string $propertyName 31 | * @param string $value 32 | * 33 | * @return Response 34 | */ 35 | public function __invoke($propertyName, $value, ProviderInterface $provider) 36 | { 37 | $this->forgotPasswordManager->resetPassword($propertyName, $value, $provider); 38 | 39 | return new Response('', 204); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Controller/UpdatePassword.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Controller; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use CoopTilleuls\ForgotPasswordBundle\Manager\ForgotPasswordManager; 18 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 19 | use Symfony\Component\HttpFoundation\Response; 20 | 21 | /** 22 | * @author Vincent CHALAMON 23 | */ 24 | final class UpdatePassword 25 | { 26 | public function __construct(private readonly ForgotPasswordManager $forgotPasswordManager) 27 | { 28 | } 29 | 30 | /** 31 | * @param string $password 32 | * 33 | * @return Response 34 | */ 35 | public function __invoke(AbstractPasswordToken $token, $password, ProviderInterface $provider) 36 | { 37 | $this->forgotPasswordManager->updatePassword($token, $password, $provider); 38 | 39 | return new Response('', 204); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/CoopTilleulsForgotPasswordBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\DependencyInjection\CompilerPass\ApiPlatformCompilerPass; 17 | use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; 18 | use Symfony\Component\DependencyInjection\ContainerBuilder; 19 | use Symfony\Component\DependencyInjection\Extension\Extension; 20 | use Symfony\Component\HttpKernel\Bundle\Bundle; 21 | 22 | /** 23 | * @author Vincent CHALAMON 24 | */ 25 | final class CoopTilleulsForgotPasswordBundle extends Bundle 26 | { 27 | public function getPath(): string 28 | { 29 | return \dirname(__DIR__); 30 | } 31 | 32 | public function build(ContainerBuilder $container): void 33 | { 34 | $container->addCompilerPass(new ApiPlatformCompilerPass()); 35 | 36 | if (class_exists(DoctrineOrmMappingsPass::class)) { 37 | $container->addCompilerPass(DoctrineOrmMappingsPass::createXmlMappingDriver([ 38 | realpath(__DIR__.'/../config/doctrine') => 'CoopTilleuls\ForgotPasswordBundle\Entity', 39 | ])); 40 | } 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | protected function getContainerExtensionClass(): string 47 | { 48 | return \sprintf( 49 | '%s\\DependencyInjection\\%s%sExtension', 50 | $this->getNamespace(), 51 | preg_replace('/Bundle$/', '', $this->getName()), 52 | class_exists(Extension::class) ? '' : 'Legacy' 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/DependencyInjection/BCExtensionTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\DependencyInjection; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Normalizer\JMSNormalizer; 17 | use CoopTilleuls\ForgotPasswordBundle\Normalizer\SymfonyNormalizer; 18 | use CoopTilleuls\ForgotPasswordBundle\Provider\Provider; 19 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 20 | use Symfony\Component\Config\FileLocator; 21 | use Symfony\Component\DependencyInjection\ContainerBuilder; 22 | use Symfony\Component\DependencyInjection\Definition; 23 | use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; 24 | use Symfony\Component\DependencyInjection\Reference; 25 | 26 | /** 27 | * @author Vincent CHALAMON 28 | */ 29 | trait BCExtensionTrait 30 | { 31 | public function load(array $configs, ContainerBuilder $container): void 32 | { 33 | $configuration = new Configuration(); 34 | $config = $this->processConfiguration($configuration, $configs); 35 | 36 | if (!$defaultProvider = $this->getDefaultProvider($config)) { 37 | throw new InvalidConfigurationException('Multiple "ForgotPassword" providers have been defined but none of them is set as default. Did you forget to set "default" option?'); 38 | } 39 | 40 | $this->buildProvider($config, $container); 41 | 42 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../../config')); 43 | $loader->load('services.xml'); 44 | 45 | // Load API-Platform bridge 46 | if (isset($container->getParameter('kernel.bundles')['ApiPlatformBundle'])) { 47 | $loader->load('api_platform.xml'); 48 | } 49 | 50 | $alias = $container->setAlias('coop_tilleuls_forgot_password.manager', $defaultProvider['manager']); 51 | if (method_exists(Definition::class, 'getDeprecation')) { 52 | $alias->setDeprecated('tilleuls/forgot-password-bundle', '1.5', 'Alias "%alias_id%" is deprecated and will be removed without replacement in 2.0.'); 53 | } else { 54 | $alias->setDeprecated(true, 'Alias "%alias_id%" is deprecated and will be removed without replacement in 2.0.'); 55 | } 56 | 57 | // Build normalizer 58 | $class = true === $config['use_jms_serializer'] ? JMSNormalizer::class : SymfonyNormalizer::class; 59 | $serializerId = true === $config['use_jms_serializer'] ? 'jms_serializer.serializer' : 'serializer'; 60 | $container->setDefinition('coop_tilleuls_forgot_password.normalizer', new Definition($class, [new Reference($serializerId)]))->setPublic(false); 61 | 62 | $container 63 | ->getDefinition('coop_tilleuls_forgot_password.manager.password_token') 64 | ->replaceArgument(0, new Reference($config['token_generator'])); 65 | } 66 | 67 | private function buildProvider(array $config, ContainerBuilder $container): void 68 | { 69 | foreach ($config['providers'] as $key => $value) { 70 | $container->setDefinition($key, new Definition(Provider::class, 71 | [ 72 | new Reference($value['manager']), 73 | $key, 74 | $value['password_token']['class'], 75 | $value['password_token']['expires_in'], 76 | $value['password_token']['user_field'], 77 | $value['user']['class'], 78 | $value['password_token']['serialization_groups'], 79 | $value['user']['email_field'], 80 | $value['user']['password_field'], 81 | array_unique(array_merge($value['user']['authorized_fields'], [$value['user']['email_field']])), 82 | $value['default'], 83 | ]))->setPublic(false) 84 | ->addTag('coop_tilleuls_forgot_password.provider'); 85 | } 86 | } 87 | 88 | private function getDefaultProvider(array $config): ?array 89 | { 90 | foreach ($config['providers'] as $value) { 91 | if (true === $value['default']) { 92 | return $value; 93 | } 94 | } 95 | 96 | return null; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/ApiPlatformCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\DependencyInjection\CompilerPass; 15 | 16 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 17 | use Symfony\Component\DependencyInjection\ContainerBuilder; 18 | 19 | /** 20 | * @author Vincent CHALAMON 21 | */ 22 | final class ApiPlatformCompilerPass implements CompilerPassInterface 23 | { 24 | public function process(ContainerBuilder $container): void 25 | { 26 | if (!$container->hasDefinition('api_platform.swagger.normalizer.documentation')) { 27 | $container->removeDefinition('coop_tilleuls_forgot_password.normalizer.documentation'); 28 | } 29 | 30 | if (!$container->hasDefinition('api_platform.openapi.factory') && !$container->hasAlias('api_platform.openapi.factory')) { 31 | $container->removeDefinition('coop_tilleuls_forgot_password.openapi.factory'); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\DependencyInjection; 15 | 16 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 17 | use Symfony\Component\Config\Definition\ConfigurationInterface; 18 | 19 | /** 20 | * @author Vincent CHALAMON 21 | */ 22 | final class Configuration implements ConfigurationInterface 23 | { 24 | public function getConfigTreeBuilder(): TreeBuilder 25 | { 26 | if (method_exists(TreeBuilder::class, 'root')) { 27 | $treeBuilder = new TreeBuilder(); 28 | $rootNode = $treeBuilder->root('coop_tilleuls_forgot_password'); 29 | } else { 30 | $treeBuilder = new TreeBuilder('coop_tilleuls_forgot_password'); 31 | $rootNode = $treeBuilder->getRootNode(); 32 | } 33 | 34 | $rootNode 35 | ->beforeNormalization() 36 | ->ifTrue(fn ($config) => \array_key_exists('password_token_class', $config) || \array_key_exists('user_class', $config)) 37 | ->then(function ($config) { 38 | if (\array_key_exists('password_token_class', $config)) { 39 | if (!isset($config['password_token'])) { 40 | $config['password_token'] = []; 41 | } 42 | $config['password_token']['class'] = $config['password_token_class']; 43 | } 44 | 45 | if (\array_key_exists('user_class', $config)) { 46 | if (!isset($config['user'])) { 47 | $config['user'] = []; 48 | } 49 | $config['user']['class'] = $config['user_class']; 50 | } 51 | unset($config['password_token_class'], $config['user_class']); 52 | 53 | return $config; 54 | }) 55 | ->ifTrue(fn ($config) => !\array_key_exists('providers', $config)) 56 | ->then(function ($config) { 57 | $config['providers']['default']['default'] = true; 58 | $config['providers']['default']['password_token'] = $config['password_token']; 59 | $config['providers']['default']['user'] = $config['user']; 60 | if (\array_key_exists('manager', $config)) { 61 | $config['providers']['default']['manager'] = $config['manager']; 62 | } 63 | unset($config['user'], $config['password_token'], $config['manager']); 64 | 65 | return $config; 66 | }) 67 | ->end() 68 | ->children() 69 | ->arrayNode('providers') 70 | ->useAttributeAsKey('name') 71 | ->prototype('array') 72 | ->children() 73 | ->scalarNode('manager') 74 | ->defaultValue('coop_tilleuls_forgot_password.manager.doctrine') 75 | ->cannotBeEmpty() 76 | ->info('Persistence manager service to handle the token storage.') 77 | ->end() 78 | ->booleanNode('default') 79 | ->defaultFalse() 80 | ->end() 81 | ->arrayNode('password_token') 82 | ->children() 83 | ->scalarNode('class') 84 | ->cannotBeEmpty() 85 | ->isRequired() 86 | ->info('PasswordToken class.') 87 | ->end() 88 | ->scalarNode('expires_in') 89 | ->defaultValue('1 day') 90 | ->cannotBeEmpty() 91 | ->info('Expiration time using Datetime format. see : https://www.php.net/manual/en/datetime.format.php.') 92 | ->end() 93 | ->scalarNode('user_field') 94 | ->defaultValue('user') 95 | ->cannotBeEmpty() 96 | ->info('User field name on PasswordToken entity.') 97 | ->end() 98 | ->arrayNode('serialization_groups') 99 | ->info('PasswordToken serialization groups.') 100 | ->defaultValue([]) 101 | ->useAttributeAsKey('name') 102 | ->prototype('scalar')->end() 103 | ->end() 104 | ->end() 105 | ->end() 106 | ->arrayNode('user') 107 | ->children() 108 | ->scalarNode('class') 109 | ->cannotBeEmpty() 110 | ->isRequired() 111 | ->info('User class.') 112 | ->end() 113 | ->scalarNode('email_field') 114 | ->defaultValue('email') 115 | ->cannotBeEmpty() 116 | ->info('User email field name to retrieve it (email, username...).') 117 | ->end() 118 | ->scalarNode('password_field') 119 | ->defaultValue('password') 120 | ->cannotBeEmpty() 121 | ->info('User password field name.') 122 | ->end() 123 | ->arrayNode('authorized_fields') 124 | ->defaultValue(['email']) 125 | ->requiresAtLeastOneElement() 126 | ->info('User fields names to retrieve it (email, username...).') 127 | ->prototype('scalar')->end() 128 | ->end() 129 | ->end() 130 | ->end() 131 | ->end() 132 | ->end() 133 | ->end() 134 | ->booleanNode('use_jms_serializer') 135 | ->defaultFalse() 136 | ->end() 137 | ->scalarNode('token_generator') 138 | ->defaultValue('coop_tilleuls_forgot_password.token_generator.bin2hex') 139 | ->cannotBeEmpty() 140 | ->info('Persistence manager service to handle the token storage.') 141 | ->end() 142 | ->end(); 143 | 144 | return $treeBuilder; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/DependencyInjection/CoopTilleulsForgotPasswordExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\DependencyInjection; 15 | 16 | use Symfony\Component\DependencyInjection\Extension\Extension; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | */ 21 | final class CoopTilleulsForgotPasswordExtension extends Extension 22 | { 23 | use BCExtensionTrait; 24 | } 25 | -------------------------------------------------------------------------------- /src/DependencyInjection/CoopTilleulsForgotPasswordLegacyExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\DependencyInjection; 15 | 16 | use Symfony\Component\HttpKernel\DependencyInjection\Extension; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | */ 21 | final class CoopTilleulsForgotPasswordLegacyExtension extends Extension 22 | { 23 | use BCExtensionTrait; 24 | } 25 | -------------------------------------------------------------------------------- /src/Entity/AbstractPasswordToken.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Entity; 15 | 16 | /** 17 | * @author Vincent CHALAMON 18 | */ 19 | abstract class AbstractPasswordToken 20 | { 21 | /** 22 | * @var string 23 | */ 24 | protected $token; 25 | 26 | /** 27 | * @var \DateTime 28 | */ 29 | protected $expiresAt; 30 | 31 | abstract public function getId(); 32 | 33 | abstract public function getUser(); 34 | 35 | abstract public function setUser($user); 36 | 37 | /** 38 | * @return string 39 | */ 40 | public function getToken() 41 | { 42 | return $this->token; 43 | } 44 | 45 | /** 46 | * @param string $token 47 | */ 48 | public function setToken($token): void 49 | { 50 | $this->token = $token; 51 | } 52 | 53 | /** 54 | * @return \DateTime 55 | */ 56 | public function getExpiresAt() 57 | { 58 | return $this->expiresAt; 59 | } 60 | 61 | public function setExpiresAt(\DateTime $expiresAt): void 62 | { 63 | $this->expiresAt = $expiresAt; 64 | } 65 | 66 | /** 67 | * @return bool 68 | */ 69 | public function isExpired() 70 | { 71 | return (new \DateTime()) > $this->expiresAt; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Event/CreateTokenEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Event; 15 | 16 | /** 17 | * @author Vincent CHALAMON 18 | */ 19 | final class CreateTokenEvent extends ForgotPasswordEvent 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Event/ForgotPasswordEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Event; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | * 21 | * @deprecated Use CreateTokenEvent and UpdatePasswordEvent instead 22 | */ 23 | class ForgotPasswordEvent extends PolyfillEvent 24 | { 25 | public const CREATE_TOKEN = 'coop_tilleuls_forgot_password.create_token'; 26 | public const UPDATE_PASSWORD = 'coop_tilleuls_forgot_password.update_password'; 27 | 28 | public function __construct(protected readonly AbstractPasswordToken $passwordToken, protected readonly ?string $password = null) 29 | { 30 | } 31 | 32 | /** 33 | * @return AbstractPasswordToken 34 | */ 35 | public function getPasswordToken() 36 | { 37 | return $this->passwordToken; 38 | } 39 | 40 | /** 41 | * @return string|null 42 | */ 43 | public function getPassword() 44 | { 45 | return $this->password; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Event/PolyfillEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Event; 15 | 16 | use Symfony\Component\EventDispatcher\Event; 17 | use Symfony\Component\EventDispatcher\EventDispatcher; 18 | use Symfony\Contracts\EventDispatcher\Event as ContractsEvent; 19 | use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; 20 | 21 | if (is_subclass_of(EventDispatcher::class, EventDispatcherInterface::class)) { 22 | // Symfony 4.4 and upper 23 | abstract class PolyfillEvent extends ContractsEvent 24 | { 25 | } 26 | } else { 27 | // Symfony 4.3 and inferior 28 | abstract class PolyfillEvent extends Event 29 | { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Event/UpdatePasswordEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Event; 15 | 16 | /** 17 | * @author Vincent CHALAMON 18 | */ 19 | final class UpdatePasswordEvent extends ForgotPasswordEvent 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Event/UserNotFoundEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Event; 15 | 16 | final class UserNotFoundEvent extends PolyfillEvent 17 | { 18 | public const USER_NOT_FOUND = 'coop_tilleuls_forgot_password.user_not_found'; 19 | 20 | public function __construct(private readonly array $context = []) 21 | { 22 | } 23 | 24 | /** 25 | * @return array 26 | */ 27 | public function getContext() 28 | { 29 | return $this->context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/EventListener/ExceptionEventListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\EventListener; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Exception\JsonHttpExceptionInterface; 17 | use Symfony\Component\HttpFoundation\JsonResponse; 18 | use Symfony\Component\HttpKernel\Event\KernelEvent; 19 | 20 | /** 21 | * @author Vincent CHALAMON 22 | */ 23 | final class ExceptionEventListener 24 | { 25 | use MainRequestTrait; 26 | 27 | public function onKernelException(KernelEvent $event): void 28 | { 29 | $exception = method_exists($event, 'getThrowable') ? $event->getThrowable() : $event->getException(); 30 | if (!$this->isMainRequest($event) || !$exception instanceof JsonHttpExceptionInterface) { 31 | return; 32 | } 33 | 34 | $event->setResponse(new JsonResponse(['message' => $exception->getMessage()], $exception->getStatusCode())); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/EventListener/MainRequestTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\EventListener; 15 | 16 | use Symfony\Component\HttpKernel\Event\KernelEvent; 17 | 18 | /** 19 | * @author Jon Gotlin 20 | */ 21 | trait MainRequestTrait 22 | { 23 | private function isMainRequest(KernelEvent $event): bool 24 | { 25 | return method_exists($event, 'isMainRequest') ? $event->isMainRequest() : $event->isMasterRequest(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/EventListener/RequestEventListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\EventListener; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Exception\InvalidJsonHttpException; 17 | use CoopTilleuls\ForgotPasswordBundle\Exception\MissingFieldHttpException; 18 | use CoopTilleuls\ForgotPasswordBundle\Exception\NoParameterException; 19 | use CoopTilleuls\ForgotPasswordBundle\Exception\UnauthorizedFieldException; 20 | use CoopTilleuls\ForgotPasswordBundle\Manager\PasswordTokenManager; 21 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderChainInterface; 22 | use Symfony\Component\HttpKernel\Event\KernelEvent; 23 | use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 24 | 25 | /** 26 | * @author Vincent CHALAMON 27 | */ 28 | final class RequestEventListener 29 | { 30 | use MainRequestTrait; 31 | 32 | public function __construct(private readonly PasswordTokenManager $passwordTokenManager, private readonly ProviderChainInterface $providerChain) 33 | { 34 | } 35 | 36 | public function decodeRequest(KernelEvent $event): void 37 | { 38 | $request = $event->getRequest(); 39 | $routeName = $request->attributes->get('_route'); 40 | if (!$this->isMainRequest($event) || !\in_array( 41 | $routeName, 42 | ['coop_tilleuls_forgot_password.reset', 'coop_tilleuls_forgot_password.update'], true 43 | ) 44 | ) { 45 | return; 46 | } 47 | 48 | $content = $request->getContent(); 49 | $data = json_decode($content, true); 50 | 51 | if (!empty($content) && \JSON_ERROR_NONE !== json_last_error()) { 52 | throw new InvalidJsonHttpException(); 53 | } 54 | if (!\is_array($data) || empty($data)) { 55 | throw new NoParameterException(); 56 | } 57 | 58 | $fieldName = key($data); 59 | if (empty($data[$fieldName])) { 60 | throw new MissingFieldHttpException($fieldName); 61 | } 62 | 63 | $provider = $this->providerChain->get($request->headers->get('FP-provider')); 64 | $request->attributes->set('provider', $provider); 65 | 66 | if ('coop_tilleuls_forgot_password.reset' === $routeName) { 67 | foreach ($data as $fieldName => $value) { 68 | if (\in_array($fieldName, $provider->getUserAuthorizedFields(), true)) { 69 | $request->attributes->set('propertyName', $fieldName); 70 | $request->attributes->set('value', $value); 71 | 72 | return; 73 | } 74 | } 75 | 76 | throw new UnauthorizedFieldException($fieldName); 77 | } 78 | 79 | // if $routeName is 'coop_tilleuls_forgot_password.update' 80 | if (!\array_key_exists($userPasswordField = $provider->getUserPasswordField(), $data)) { 81 | throw new MissingFieldHttpException($userPasswordField); 82 | } 83 | 84 | $request->attributes->set('password', $data[$userPasswordField]); 85 | } 86 | 87 | public function getTokenFromRequest(KernelEvent $event): void 88 | { 89 | $request = $event->getRequest(); 90 | $routeName = $request->attributes->get('_route'); 91 | if (!$this->isMainRequest($event) || !\in_array( 92 | $routeName, 93 | ['coop_tilleuls_forgot_password.get_token', 'coop_tilleuls_forgot_password.update'], true 94 | ) 95 | ) { 96 | return; 97 | } 98 | 99 | $provider = $this->providerChain->get($request->headers->get('FP-provider')); 100 | $token = $this->passwordTokenManager->findOneByToken($request->attributes->get('tokenValue'), $provider); 101 | 102 | if (null === $token || $token->isExpired()) { 103 | throw new NotFoundHttpException('Invalid token.'); 104 | } 105 | 106 | $request->attributes->set('token', $token); 107 | $request->attributes->set('provider', $provider); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Exception/InvalidJsonHttpException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpException; 17 | 18 | final class InvalidJsonHttpException extends HttpException implements JsonHttpExceptionInterface 19 | { 20 | public function __construct() 21 | { 22 | parent::__construct(422, 'Invalid JSON data.'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/JsonHttpExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | */ 21 | interface JsonHttpExceptionInterface extends HttpExceptionInterface 22 | { 23 | } 24 | -------------------------------------------------------------------------------- /src/Exception/MissingFieldHttpException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpException; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | */ 21 | final class MissingFieldHttpException extends HttpException implements JsonHttpExceptionInterface 22 | { 23 | public function __construct($fieldName) 24 | { 25 | parent::__construct(422, \sprintf('Parameter "%s" is missing.', $fieldName)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/NoParameterException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpException; 17 | 18 | final class NoParameterException extends HttpException implements JsonHttpExceptionInterface 19 | { 20 | public function __construct() 21 | { 22 | parent::__construct(422, 'No parameter sent.'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/UnauthorizedFieldException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpException; 17 | 18 | final class UnauthorizedFieldException extends HttpException implements JsonHttpExceptionInterface 19 | { 20 | public function __construct($propertyName) 21 | { 22 | parent::__construct(422, \sprintf('The parameter "%s" is not authorized in your configuration.', $propertyName)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/UndefinedProviderException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Exception; 15 | 16 | use Symfony\Component\HttpKernel\Exception\HttpException; 17 | 18 | final class UndefinedProviderException extends HttpException implements JsonHttpExceptionInterface 19 | { 20 | public function __construct(string $message = 'This provider is not defined.') 21 | { 22 | parent::__construct(422, $message); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Manager/Bridge/DoctrineManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Manager\Bridge; 15 | 16 | use Doctrine\Common\Persistence\ManagerRegistry as LegacyManagerRegistry; 17 | use Doctrine\Persistence\ManagerRegistry; 18 | 19 | /** 20 | * @author Vincent CHALAMON 21 | */ 22 | final class DoctrineManager implements ManagerInterface 23 | { 24 | public function __construct(private readonly LegacyManagerRegistry|ManagerRegistry $registry) 25 | { 26 | } 27 | 28 | public function findOneBy($class, array $criteria) 29 | { 30 | return $this->registry->getManagerForClass($class) 31 | ->getRepository($class) 32 | ->findOneBy($criteria); 33 | } 34 | 35 | public function persist($object): void 36 | { 37 | $manager = $this->registry->getManagerForClass($object::class); 38 | $manager->persist($object); 39 | $manager->flush(); 40 | } 41 | 42 | public function remove($object): void 43 | { 44 | $manager = $this->registry->getManagerForClass($object::class); 45 | $manager->remove($object); 46 | $manager->flush(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Manager/Bridge/ManagerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Manager\Bridge; 15 | 16 | /** 17 | * @author Vincent CHALAMON 18 | */ 19 | interface ManagerInterface 20 | { 21 | /** 22 | * @param string $class 23 | * 24 | * @return mixed|null 25 | */ 26 | public function findOneBy($class, array $criteria); 27 | 28 | public function persist($object); 29 | 30 | public function remove($object); 31 | } 32 | -------------------------------------------------------------------------------- /src/Manager/ForgotPasswordManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Manager; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use CoopTilleuls\ForgotPasswordBundle\Event\CreateTokenEvent; 18 | use CoopTilleuls\ForgotPasswordBundle\Event\ForgotPasswordEvent; 19 | use CoopTilleuls\ForgotPasswordBundle\Event\UpdatePasswordEvent; 20 | use CoopTilleuls\ForgotPasswordBundle\Event\UserNotFoundEvent; 21 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 22 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 23 | use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as ContractsEventDispatcherInterface; 24 | 25 | /** 26 | * @author Vincent CHALAMON 27 | */ 28 | class ForgotPasswordManager 29 | { 30 | public function __construct(private readonly PasswordTokenManager $passwordTokenManager, private readonly EventDispatcherInterface $dispatcher) 31 | { 32 | } 33 | 34 | public function resetPassword($propertyName, $value, ProviderInterface $provider): void 35 | { 36 | $context = [$propertyName => $value]; 37 | 38 | $user = $provider->getManager()->findOneBy($provider->getUserClass(), $context); 39 | 40 | if (null === $user) { 41 | if ($this->dispatcher instanceof ContractsEventDispatcherInterface) { 42 | $this->dispatcher->dispatch(new UserNotFoundEvent($context)); 43 | } else { 44 | $this->dispatcher->dispatch(UserNotFoundEvent::USER_NOT_FOUND, new UserNotFoundEvent($context)); 45 | } 46 | 47 | return; 48 | } 49 | 50 | $token = $this->passwordTokenManager->findOneByUser($user, $provider); 51 | 52 | // A token already exists and has not expired 53 | if (null === $token || $token->isExpired()) { 54 | $expiredAt = new \DateTime($provider->getPasswordTokenExpiredIn()); 55 | $expiredAt->setTime((int) $expiredAt->format('H'), (int) $expiredAt->format('i'), (int) $expiredAt->format('s'), 0); 56 | 57 | $token = $this->passwordTokenManager->createPasswordToken($user, $provider, $expiredAt); 58 | } 59 | 60 | // Generate password token 61 | if ($this->dispatcher instanceof ContractsEventDispatcherInterface) { 62 | $this->dispatcher->dispatch(new CreateTokenEvent($token)); 63 | } else { 64 | $this->dispatcher->dispatch(ForgotPasswordEvent::CREATE_TOKEN, new CreateTokenEvent($token)); 65 | } 66 | } 67 | 68 | /** 69 | * @param string $password 70 | * 71 | * @return bool 72 | */ 73 | public function updatePassword(AbstractPasswordToken $passwordToken, $password, ProviderInterface $provider) 74 | { 75 | // Update user password 76 | if ($this->dispatcher instanceof ContractsEventDispatcherInterface) { 77 | $this->dispatcher->dispatch(new UpdatePasswordEvent($passwordToken, $password)); 78 | } else { 79 | $this->dispatcher->dispatch(ForgotPasswordEvent::UPDATE_PASSWORD, new UpdatePasswordEvent($passwordToken, $password)); 80 | } 81 | 82 | // Remove PasswordToken 83 | $provider->getManager()->remove($passwordToken); 84 | 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Manager/PasswordTokenManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Manager; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use CoopTilleuls\ForgotPasswordBundle\Provider\ProviderInterface; 18 | use CoopTilleuls\ForgotPasswordBundle\TokenGenerator\TokenGeneratorInterface; 19 | 20 | /** 21 | * @author Vincent CHALAMON 22 | */ 23 | class PasswordTokenManager 24 | { 25 | public function __construct(private readonly TokenGeneratorInterface $tokenGenerator) 26 | { 27 | } 28 | 29 | /** 30 | * @return AbstractPasswordToken 31 | */ 32 | public function createPasswordToken($user, ProviderInterface $provider, ?\DateTime $expiresAt = null) 33 | { 34 | if (!$expiresAt) { 35 | $expiresAt = new \DateTime($provider->getPasswordTokenExpiredIn()); 36 | $expiresAt->setTime((int) $expiresAt->format('H'), (int) $expiresAt->format('i'), (int) $expiresAt->format('s'), 0); 37 | } 38 | 39 | $tokenClass = $provider->getPasswordTokenClass(); 40 | 41 | /** @var AbstractPasswordToken $passwordToken */ 42 | $passwordToken = new $tokenClass(); 43 | $passwordToken->setToken($this->tokenGenerator->generate()); 44 | $passwordToken->setUser($user); 45 | $passwordToken->setExpiresAt($expiresAt); 46 | $provider->getManager()->persist($passwordToken); 47 | 48 | return $passwordToken; 49 | } 50 | 51 | /** 52 | * @param string $token 53 | * 54 | * @return AbstractPasswordToken 55 | */ 56 | public function findOneByToken($token, ProviderInterface $provider) 57 | { 58 | return $provider->getManager()->findOneBy($provider->getPasswordTokenClass(), ['token' => $token]); 59 | } 60 | 61 | /** 62 | * @return AbstractPasswordToken 63 | */ 64 | public function findOneByUser($user, ProviderInterface $provider) 65 | { 66 | return $provider->getManager()->findOneBy($provider->getPasswordTokenClass(), [$provider->getPasswordTokenUserField() => $user]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Normalizer/JMSNormalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Normalizer; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use JMS\Serializer\ArrayTransformerInterface; 18 | 19 | /** 20 | * @author Vincent CHALAMON 21 | */ 22 | final class JMSNormalizer implements NormalizerInterface 23 | { 24 | public function __construct(private readonly ArrayTransformerInterface $normalizer) 25 | { 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function normalize(AbstractPasswordToken $object, $format, array $context = []) 32 | { 33 | return $this->normalizer->toArray($object); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Normalizer/NormalizerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Normalizer; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | 18 | /** 19 | * @author Vincent CHALAMON 20 | */ 21 | interface NormalizerInterface 22 | { 23 | /** 24 | * @param string $format 25 | */ 26 | public function normalize(AbstractPasswordToken $object, $format, array $context = []); 27 | } 28 | -------------------------------------------------------------------------------- /src/Normalizer/SymfonyNormalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Normalizer; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; 17 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface as SymfonyNormalizerInterface; 18 | 19 | /** 20 | * @author Vincent CHALAMON 21 | */ 22 | final class SymfonyNormalizer implements NormalizerInterface 23 | { 24 | public function __construct(private readonly SymfonyNormalizerInterface $normalizer) 25 | { 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function normalize(AbstractPasswordToken $object, $format, array $context = []) 32 | { 33 | return $this->normalizer->normalize($object, $format, $context); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Provider/Provider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Provider; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Manager\Bridge\ManagerInterface; 17 | 18 | final class Provider implements ProviderInterface 19 | { 20 | public function __construct(private readonly ManagerInterface $manager, private readonly string $name, private readonly string $passwordTokenClass, private readonly string $passwordTokenExpiredIn, private readonly string $passwordTokenUserField, private readonly string $userClass, private readonly array $passwordTokenSerializationGroups = [], private readonly string $userEmailField = 'email', private readonly string $userPasswordField = 'password', private readonly array $userAuthorizedFields = [], private readonly bool $isDefault = false) 21 | { 22 | } 23 | 24 | public function getManager(): ManagerInterface 25 | { 26 | return $this->manager; 27 | } 28 | 29 | public function getUserClass(): string 30 | { 31 | return $this->userClass; 32 | } 33 | 34 | public function getPasswordTokenClass(): string 35 | { 36 | return $this->passwordTokenClass; 37 | } 38 | 39 | public function getPasswordTokenExpiredIn(): string 40 | { 41 | return $this->passwordTokenExpiredIn; 42 | } 43 | 44 | public function getPasswordTokenUserField(): string 45 | { 46 | return $this->passwordTokenUserField; 47 | } 48 | 49 | public function getPasswordTokenSerializationGroups(): array 50 | { 51 | return $this->passwordTokenSerializationGroups; 52 | } 53 | 54 | public function getUserEmailField(): string 55 | { 56 | return $this->userEmailField; 57 | } 58 | 59 | public function getUserPasswordField(): string 60 | { 61 | return $this->userPasswordField; 62 | } 63 | 64 | public function getUserAuthorizedFields(): array 65 | { 66 | return $this->userAuthorizedFields; 67 | } 68 | 69 | public function isDefault(): bool 70 | { 71 | return $this->isDefault; 72 | } 73 | 74 | public function getName(): string 75 | { 76 | return $this->name; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Provider/ProviderChain.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Provider; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Exception\UndefinedProviderException; 17 | 18 | final class ProviderChain implements ProviderChainInterface 19 | { 20 | /** 21 | * @var array 22 | */ 23 | private array $providers; 24 | 25 | public function __construct(iterable $providers) 26 | { 27 | $this->providers = iterator_to_array($providers); 28 | } 29 | 30 | /** 31 | * Returns a provider by its name, without name the default provider is returned. 32 | */ 33 | public function get(?string $name = null): ProviderInterface 34 | { 35 | if (null === $name) { 36 | return $this->getDefault(); 37 | } 38 | 39 | if (!isset($this->providers[$name])) { 40 | throw new UndefinedProviderException(\sprintf('The provider "%s" is not defined.', $name)); 41 | } 42 | 43 | return $this->providers[$name]; 44 | } 45 | 46 | /** 47 | * Returns all providers indexed by name. 48 | * 49 | * @return array 50 | */ 51 | public function all(): iterable 52 | { 53 | return $this->providers; 54 | } 55 | 56 | private function getDefault(): ProviderInterface 57 | { 58 | foreach ($this->providers as $provider) { 59 | if (true === $provider->isDefault()) { 60 | return $provider; 61 | } 62 | } 63 | 64 | throw new UndefinedProviderException(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Provider/ProviderChainInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Provider; 15 | 16 | interface ProviderChainInterface 17 | { 18 | public function get(?string $name = null): ProviderInterface; 19 | 20 | /** 21 | * @return array 22 | */ 23 | public function all(): iterable; 24 | } 25 | -------------------------------------------------------------------------------- /src/Provider/ProviderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Provider; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\Manager\Bridge\ManagerInterface; 17 | 18 | /** 19 | * Configuration of ForgotPassword for each provider. 20 | */ 21 | interface ProviderInterface 22 | { 23 | /** 24 | * User Class. 25 | */ 26 | public function getUserClass(): string; 27 | 28 | /** 29 | * PasswordToken Class. 30 | */ 31 | public function getPasswordTokenClass(): string; 32 | 33 | /** 34 | * PasswordToken expiration property. 35 | */ 36 | public function getPasswordTokenExpiredIn(): string; 37 | 38 | /** 39 | * PasswordToken user field property. 40 | */ 41 | public function getPasswordTokenUserField(): string; 42 | 43 | /** 44 | * PasswordToken serialization groups. 45 | */ 46 | public function getPasswordTokenSerializationGroups(): array; 47 | 48 | /** 49 | * User email property. 50 | */ 51 | public function getUserEmailField(): string; 52 | 53 | /** 54 | * User password property. 55 | */ 56 | public function getUserPasswordField(): string; 57 | 58 | /** 59 | * Manager to handle related objects. 60 | */ 61 | public function getManager(): ManagerInterface; 62 | 63 | /** 64 | * User password/email property authorized. 65 | */ 66 | public function getUserAuthorizedFields(): array; 67 | 68 | /** 69 | * If provider is Default no need to mention it in queries. 70 | */ 71 | public function isDefault(): bool; 72 | 73 | public function getName(): string; 74 | } 75 | -------------------------------------------------------------------------------- /src/Routing/RouteLoader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\Routing; 15 | 16 | use Symfony\Component\Config\Loader\Loader; 17 | use Symfony\Component\Routing\Route; 18 | use Symfony\Component\Routing\RouteCollection; 19 | 20 | /** 21 | * @author Vincent CHALAMON 22 | */ 23 | final class RouteLoader extends Loader 24 | { 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function load($resource, $type = null): RouteCollection 29 | { 30 | $collection = new RouteCollection(); 31 | $collection->add( 32 | 'coop_tilleuls_forgot_password.reset', 33 | (new Route('/', [ 34 | '_controller' => 'coop_tilleuls_forgot_password.controller.reset_password', 35 | ]))->setMethods('POST') 36 | ); 37 | $collection->add( 38 | 'coop_tilleuls_forgot_password.update', 39 | (new Route('/{tokenValue}', [ 40 | '_controller' => 'coop_tilleuls_forgot_password.controller.update_password', 41 | ]))->setMethods('POST') 42 | ); 43 | $collection->add( 44 | 'coop_tilleuls_forgot_password.get_token', 45 | (new Route('/{tokenValue}', [ 46 | '_controller' => 'coop_tilleuls_forgot_password.controller.get_token', 47 | ]))->setMethods('GET') 48 | ); 49 | 50 | return $collection; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function supports($resource, $type = null): bool 57 | { 58 | return 'coop_tilleuls_forgot_password' === $type; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/TokenGenerator/Bridge/Bin2HexTokenGenerator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\TokenGenerator\Bridge; 15 | 16 | use CoopTilleuls\ForgotPasswordBundle\TokenGenerator\TokenGeneratorInterface; 17 | 18 | final class Bin2HexTokenGenerator implements TokenGeneratorInterface 19 | { 20 | public function generate(): string 21 | { 22 | return bin2hex(random_bytes(25)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/TokenGenerator/TokenGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace CoopTilleuls\ForgotPasswordBundle\TokenGenerator; 15 | 16 | /** 17 | * @author Vincent CHALAMON 18 | */ 19 | interface TokenGeneratorInterface 20 | { 21 | public function generate(): string; 22 | } 23 | --------------------------------------------------------------------------------