Skip to content

Commit

Permalink
feat(UserBundle): Extract common logic between UserInput and Password…
Browse files Browse the repository at this point in the history
…lessUserInput
  • Loading branch information
ambroisemaupate committed Sep 18, 2024
1 parent c86889d commit bd11480
Show file tree
Hide file tree
Showing 11 changed files with 137 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions lib/RoadizUserBundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions lib/RoadizUserBundle/config/api_resources/user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/RoadizUserBundle/config/routing.yaml
Original file line number Diff line number Diff line change
@@ -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]
3 changes: 3 additions & 0 deletions lib/RoadizUserBundle/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' ]
22 changes: 22 additions & 0 deletions lib/RoadizUserBundle/src/Api/Dto/AbstractUserInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\UserBundle\Api\Dto;

use Symfony\Component\Validator\Constraints as Assert;

abstract class AbstractUserInput
{
#[Assert\Email]
#[Assert\NotNull]
public string $email = '';
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $publicName = null;
public ?string $phone = null;
public ?string $company = null;
public ?string $job = null;
public ?\DateTime $birthday = null;
public ?array $metadata = null;
}
14 changes: 1 addition & 13 deletions lib/RoadizUserBundle/src/Api/Dto/PasswordlessUserInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,6 @@

namespace RZ\Roadiz\UserBundle\Api\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class PasswordlessUserInput
final class PasswordlessUserInput extends AbstractUserInput
{
#[Assert\Email]
#[Assert\NotNull]
public string $email = '';
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $phone = null;
public ?string $company = null;
public ?string $job = null;
public ?\DateTime $birthday = null;
public ?array $metadata = null;
}
14 changes: 1 addition & 13 deletions lib/RoadizUserBundle/src/Api/Dto/UserInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,10 @@

use Symfony\Component\Validator\Constraints as Assert;

final class UserInput
final class UserInput extends AbstractUserInput
{
#[Assert\Email]
#[Assert\NotNull]
public string $email = '';

#[Assert\NotNull]
#[Assert\NotBlank]
#[Assert\NotCompromisedPassword()]
public string $plainPassword = '';
public ?string $publicName = null;
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $phone = null;
public ?string $company = null;
public ?string $job = null;
public ?\DateTime $birthday = null;
public ?array $metadata = null;
}
44 changes: 19 additions & 25 deletions lib/RoadizUserBundle/src/State/PasswordlessUserSignupProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,15 @@
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\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final readonly class PasswordlessUserSignupProcessor implements ProcessorInterface
{
use RecaptchaProtectedTrait;
use SignupProcessorTrait;

public function __construct(
private LoginLinkHandlerInterface $loginLinkHandler,
Expand Down Expand Up @@ -56,38 +55,31 @@ 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 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());

Expand All @@ -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);

Expand Down
50 changes: 50 additions & 0 deletions lib/RoadizUserBundle/src/State/SignupProcessorTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\UserBundle\State;

use RZ\Roadiz\CoreBundle\Entity\User;
use RZ\Roadiz\UserBundle\Api\Dto\AbstractUserInput;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;

trait SignupProcessorTrait
{
abstract protected function getSecurity(): Security;
abstract protected function getUserSignupLimiter(): RateLimiterFactory;

protected function validateRequest(?Request $request): void
{
if ($this->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;
}
}
40 changes: 14 additions & 26 deletions lib/RoadizUserBundle/src/State/UserSignupProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down

0 comments on commit bd11480

Please sign in to comment.