diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/JwtAuthenticationSuccessEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/JwtAuthenticationSuccessEventSubscriber.php index 5c13026b..3624b404 100644 --- a/lib/RoadizCoreBundle/src/EventSubscriber/JwtAuthenticationSuccessEventSubscriber.php +++ b/lib/RoadizCoreBundle/src/EventSubscriber/JwtAuthenticationSuccessEventSubscriber.php @@ -30,9 +30,7 @@ public static function getSubscribedEvents(): array public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void { $user = $event->getUser(); - /* - * Check if user is a passwordless user - */ + if (!($user instanceof User)) { return; } diff --git a/lib/RoadizUserBundle/README.md b/lib/RoadizUserBundle/README.md index 7d4fe16a..2c588afb 100644 --- a/lib/RoadizUserBundle/README.md +++ b/lib/RoadizUserBundle/README.md @@ -106,6 +106,10 @@ nelmio_cors: ## Passwordless user creation and authentication +You can switch your public users to `PasswordlessUser` and set up a login link authentication process along with +user creation process. + +First you need to configure a public login link route: ```yaml # config/routes.yaml @@ -114,6 +118,9 @@ public_login_link_check: methods: [POST] ``` +Then you need to configure your security.yaml file to use `login_link` authentication process in your API firewall. +You **must** use `all_users` provider to be able to use Roadiz User provider during the login_link authentication process. + ```yaml # config/packages/security.yaml # https://symfony.com/bundles/LexikJWTAuthenticationBundle/current/8-jwt-user-provider.html#symfony-5-3-and-higher @@ -135,6 +142,12 @@ api: max_uses: 3 ``` +## Public users roles + +- `ROLE_PUBLIC_USER`: Default role for public users +- `ROLE_PASSWORDLESS_USER`: Role for public users authenticated with a login link +- `ROLE_EMAIL_VALIDATED`: Role for public users added since they validated their email address, through a validation token or a login link + ## Maintenance commands diff --git a/lib/RoadizUserBundle/config/api_resources/user.yaml b/lib/RoadizUserBundle/config/api_resources/user.yaml index fc4729b0..18c81dd8 100644 --- a/lib/RoadizUserBundle/config/api_resources/user.yaml +++ b/lib/RoadizUserBundle/config/api_resources/user.yaml @@ -5,16 +5,23 @@ RZ\Roadiz\CoreBundle\Entity\User: class: ApiPlatform\Metadata\Post method: 'POST' uriTemplate: '/users/signup' + # + # For classic User with password, you can use this configuration + # processor: RZ\Roadiz\UserBundle\State\UserSignupProcessor input: RZ\Roadiz\UserBundle\Api\Dto\UserInput output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput validation_groups: - no_empty_password + # # For passwordless user creation, you can use this configuration + # #processor: RZ\Roadiz\UserBundle\State\PasswordlessUserSignupProcessor #input: RZ\Roadiz\UserBundle\Api\Dto\PasswordlessUserInput #output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + # # Do not use no_empty_password for passwordless user creation + # #validation_groups: ~ openapiContext: summary: Create a new public user diff --git a/lib/RoadizUserBundle/config/routing.yaml b/lib/RoadizUserBundle/config/routing.yaml index fe13e99e..10850755 100644 --- a/lib/RoadizUserBundle/config/routing.yaml +++ b/lib/RoadizUserBundle/config/routing.yaml @@ -1 +1,7 @@ # see api_resources/user.yaml for API Platform auto routing +# +# Use Passwordless users with login link +# +#public_login_link_check: +# path: /api/users/login_link_check +# methods: [POST] diff --git a/lib/RoadizUserBundle/config/services.yaml b/lib/RoadizUserBundle/config/services.yaml index d9c501fd..8d2f911d 100644 --- a/lib/RoadizUserBundle/config/services.yaml +++ b/lib/RoadizUserBundle/config/services.yaml @@ -30,3 +30,6 @@ services: RZ\Roadiz\UserBundle\State\UserSignupProcessor: tags: [ 'api_platform.state_processor' ] + + RZ\Roadiz\UserBundle\State\PasswordlessUserSignupProcessor: + tags: [ 'api_platform.state_processor' ] diff --git a/lib/RoadizUserBundle/src/Api/Dto/AbstractUserInput.php b/lib/RoadizUserBundle/src/Api/Dto/AbstractUserInput.php new file mode 100644 index 00000000..6e535f55 --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/Dto/AbstractUserInput.php @@ -0,0 +1,22 @@ +recaptchaHeaderName; } + protected function getSecurity(): Security + { + return $this->security; + } + + protected function getUserSignupLimiter(): RateLimiterFactory + { + return $this->userSignupLimiter; + } + public function process($data, Operation $operation, array $uriVariables = [], array $context = []): VoidOutput { if (!$data instanceof PasswordlessUserInput) { throw new BadRequestHttpException(sprintf('Cannot process %s', get_class($data))); } - - if ($this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Cannot sign-up: you\'re already authenticated.'); - } - $request = $this->requestStack->getCurrentRequest(); - if (null !== $request) { - $limiter = $this->userSignupLimiter->create($request->getClientIp()); - $limit = $limiter->consume(); - if (false === $limit->isAccepted()) { - throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); - } - } - + $this->ValidateRequest($request); $this->validateRecaptchaHeader($request); - $user = new User(); - $user->setEmail($data->email); - $user->setUsername($data->email); - $user->setFirstName($data->firstName); - $user->setLastName($data->lastName); - $user->setPhone($data->phone); - $user->setCompany($data->company); - $user->setJob($data->job); - $user->setBirthday($data->birthday); + $user = $this->createUser($data); $user->addRoleEntity($this->rolesBag->get($this->publicUserRoleName)); $user->addRoleEntity($this->rolesBag->get($this->passwordlessUserRoleName)); + /* + * We don't want to send an email right now, we will send a login link instead. + */ $user->sendCreationConfirmationEmail(false); $user->setLocale($request->getLocale()); @@ -103,7 +95,9 @@ public function process($data, Operation $operation, array $uriVariables = [], a $this->persistProcessor->process($userMetadata, $operation, $uriVariables, $context); } - # Send user first login link + /* + * Send user first login link, this will also set user as EMAIL_VALIDATED + */ $loginLinkDetails = $this->loginLinkHandler->createLoginLink($user, $request); $this->loginLinkSender->sendLoginLink($user, $loginLinkDetails); diff --git a/lib/RoadizUserBundle/src/State/SignupProcessorTrait.php b/lib/RoadizUserBundle/src/State/SignupProcessorTrait.php new file mode 100644 index 00000000..8f942c86 --- /dev/null +++ b/lib/RoadizUserBundle/src/State/SignupProcessorTrait.php @@ -0,0 +1,50 @@ +getSecurity()->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Cannot sign-up: you\'re already authenticated.'); + } + + if (null !== $request) { + $limiter = $this->getUserSignupLimiter()->create($request->getClientIp()); + $limit = $limiter->consume(); + if (false === $limit->isAccepted()) { + throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); + } + } + } + + protected function createUser(AbstractUserInput $data): User + { + $user = new User(); + $user->setEmail($data->email); + $user->setUsername($data->email); + $user->setFirstName($data->firstName); + $user->setLastName($data->lastName); + $user->setPublicName($data->publicName); + $user->setPhone($data->phone); + $user->setCompany($data->company); + $user->setJob($data->job); + $user->setBirthday($data->birthday); + + return $user; + } +} diff --git a/lib/RoadizUserBundle/src/State/UserSignupProcessor.php b/lib/RoadizUserBundle/src/State/UserSignupProcessor.php index a1449415..e0115bc9 100644 --- a/lib/RoadizUserBundle/src/State/UserSignupProcessor.php +++ b/lib/RoadizUserBundle/src/State/UserSignupProcessor.php @@ -8,23 +8,21 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\ValidatorInterface; use RZ\Roadiz\CoreBundle\Bag\Roles; -use RZ\Roadiz\CoreBundle\Entity\User; use RZ\Roadiz\CoreBundle\Form\Constraint\RecaptchaServiceInterface; use RZ\Roadiz\UserBundle\Api\Dto\UserInput; use RZ\Roadiz\UserBundle\Api\Dto\VoidOutput; use RZ\Roadiz\UserBundle\Event\UserSignedUp; use RZ\Roadiz\UserBundle\Manager\UserMetadataManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\RateLimiter\RateLimiterFactory; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; final readonly class UserSignupProcessor implements ProcessorInterface { use RecaptchaProtectedTrait; + use SignupProcessorTrait; public function __construct( private ValidatorInterface $validator, @@ -51,37 +49,27 @@ protected function getRecaptchaHeaderName(): string return $this->recaptchaHeaderName; } + protected function getSecurity(): Security + { + return $this->security; + } + + protected function getUserSignupLimiter(): RateLimiterFactory + { + return $this->userSignupLimiter; + } + public function process($data, Operation $operation, array $uriVariables = [], array $context = []): VoidOutput { if (!$data instanceof UserInput) { throw new BadRequestHttpException(sprintf('Cannot process %s', get_class($data))); } - if ($this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Cannot sign-up: you\'re already authenticated.'); - } - $request = $this->requestStack->getCurrentRequest(); - if (null !== $request) { - $limiter = $this->userSignupLimiter->create($request->getClientIp()); - $limit = $limiter->consume(); - if (false === $limit->isAccepted()) { - throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); - } - } - + $this->ValidateRequest($request); $this->validateRecaptchaHeader($request); - $user = new User(); - $user->setEmail($data->email); - $user->setUsername($data->email); - $user->setFirstName($data->firstName); - $user->setPublicName($data->publicName); - $user->setLastName($data->lastName); - $user->setPhone($data->phone); - $user->setCompany($data->company); - $user->setJob($data->job); - $user->setBirthday($data->birthday); + $user = $this->createUser($data); $user->setPlainPassword($data->plainPassword); $user->addRoleEntity($this->rolesBag->get($this->publicUserRoleName)); $user->sendCreationConfirmationEmail(true);