Skip to content

Commit

Permalink
feat(UserBundle): Added password-less public user creation and login …
Browse files Browse the repository at this point in the history
…link
  • Loading branch information
ambroisemaupate committed Sep 12, 2024
1 parent fbe532f commit c86889d
Show file tree
Hide file tree
Showing 30 changed files with 533 additions and 102 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
meta {
name: Check a public login-link
type: http
seq: 10
}

post {
url: {{baseUrl}}/api/users/login_link_check
body: formUrlEncoded
auth: none
}

params:query {
_locale: fr
}

headers {
x-g-recaptcha-response: test
}

body:form-urlencoded {
user: [email protected]
expires: 1726163298
hash: 63c1K5rt7Bswx5jCE-HpP3RI5Y843dgRtcCNiXrWrQ~PFGtvAdPfe7dCJ9ic6QyiGmN5sDG9nPnYZpqEykAmpQ~
}

script:post-response {
const data = res.getBody();
bru.setEnvVar("access_token", data.token);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
meta {
name: Create a new public passwordless user
type: http
seq: 9
}

post {
url: {{baseUrl}}/api/users/signup?_locale=fr
body: json
auth: none
}

params:query {
_locale: fr
}

headers {
x-g-recaptcha-response: test
}

body:json {
{
"email": "[email protected]",
"firstName": "Ambroise",
"lastName": "Maupate",
"company": "Rezo Zero",
"metadata": {
"press": {
"press": true,
"educational": false
},
"educational": false
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
meta {
name: Get current user -JWT- information Copy
name: Get current user JWT information
type: http
seq: 3
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ auth:bearer {

body:json {
{
"token": "vJSMM5V5B5hPZlJBG214OJsJRO_yNsMBYczXZE_EJGI"
"token": "Y_au0OF8MMmM3DZLcwRgmqE_ZR84JPqr-_Fw3bWs2E"
}
}
14 changes: 10 additions & 4 deletions config/api_resources/user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ resources:
method: 'POST'
uriTemplate: '/users/signup'
itemUriTemplate: '/users/{id}'
processor: RZ\Roadiz\UserBundle\State\UserSignupProcessor
input: RZ\Roadiz\UserBundle\Api\Dto\UserInput
# 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
validation_groups:
- no_empty_password
# Do not use no_empty_password for passwordless user creation
#validation_groups: ~
openapiContext:
summary: Create a new public user
parameters:
Expand Down
17 changes: 13 additions & 4 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ security:
property: username
all_users:
chain:
providers: [ 'openid_user_provider', 'roadiz_user_provider' ]
providers: [ 'roadiz_user_provider', 'openid_user_provider' ]

firewalls:
dev:
Expand Down Expand Up @@ -47,11 +47,20 @@ security:
# Do not reload user from database, trust JWT roles in order to restrict PreviewUsers
# Only drawback is when you want to disable / block / expire a user, you'll have to
# wait for JWT token to expire.
provider: jwt
#provider: jwt
# If you really want to reload user from database, uncomment this line, but Preview JWT
# will be reloaded as full user and not as PreviewUser.
#provider: all_users
provider: all_users
jwt: ~
login_link:
check_route: public_login_link_check
check_post_only: true
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
signature_properties: [ 'email' ]
# lifetime in seconds
lifetime: 600
max_uses: 10

# disables session creation for assets and healthcheck controllers
assets:
Expand Down Expand Up @@ -88,7 +97,7 @@ security:
# lifetime in seconds
lifetime: 300
# only allow the link to be used 3 times
max_uses: 3
max_uses: 10
success_handler: RZ\Roadiz\CoreBundle\Security\Authentication\BackofficeAuthenticationSuccessHandler
login_throttling:
max_attempts: 3
Expand Down
4 changes: 4 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ api_contact_form_post:
_controller: App\Controller\ContactFormController::formAction
_locale: fr
_format: json

public_login_link_check:
path: /api/users/login_link_check
methods: [POST]
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\CoreBundle\Security\LoginLink;

use RZ\Roadiz\CoreBundle\Bag\Settings;
use RZ\Roadiz\CoreBundle\Entity\User;
use RZ\Roadiz\CoreBundle\Mailer\EmailManagerFactory;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkDetails;
use Symfony\Contracts\Translation\TranslatorInterface;

final readonly class EmailLoginLinkSender implements LoginLinkSenderInterface
{
public function __construct(
private Settings $settingsBag,
private EmailManagerFactory $emailManagerFactory,
private TranslatorInterface $translator,
private string $htmlTemplate = '@RoadizCore/email/users/login_link_email.html.twig',
private string $txtTemplate = '@RoadizCore/email/users/login_link_email.txt.twig'
) {
}

public function sendLoginLink(UserInterface $user, LoginLinkDetails $loginLinkDetails,): void
{
if ($user instanceof User && !$user->isEnabled()) {
throw new \InvalidArgumentException('User must be enabled to send a login link.');
}

if (!\method_exists($user, 'getEmail')) {
throw new \InvalidArgumentException('User implementation must have getEmail method.');
}

if (null === $user->getEmail()) {
throw new \InvalidArgumentException('User must have an email to send a login link.');
}

$emailManager = $this->emailManagerFactory->create();
$emailContact = $this->settingsBag->get('email_sender', null);
if (!\is_string($emailContact)) {
throw new \InvalidArgumentException('Email sender must be a string.');
}
$siteName = $this->settingsBag->get('site_name', null);
if (!\is_string($siteName)) {
throw new \InvalidArgumentException('Site name must be a string.');
}

$emailManager->setAssignation([
'loginLink' => $loginLinkDetails->getUrl(),
'expiresAt' => $loginLinkDetails->getExpiresAt(),
'user' => $user,
'site' => $siteName,
'mailContact' => $emailContact,
]);
$emailManager->setEmailTemplate($this->htmlTemplate);
$emailManager->setEmailPlainTextTemplate($this->txtTemplate);
$emailManager->setSubject($this->translator->trans(
'login_link.request'
));

$emailManager->setReceiver($user->getEmail());
$emailManager->setSender([$emailContact => $siteName]);

// Send the message
$emailManager->send();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\CoreBundle\Security\LoginLink;

use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkDetails;

interface LoginLinkSenderInterface
{
public function sendLoginLink(
UserInterface $user,
LoginLinkDetails $loginLinkDetails,
): void;
}
34 changes: 7 additions & 27 deletions lib/RoadizCoreBundle/src/Security/User/UserViewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use RZ\Roadiz\CoreBundle\Bag\Settings;
use RZ\Roadiz\CoreBundle\Entity\User;
use RZ\Roadiz\CoreBundle\Mailer\EmailManagerFactory;
use RZ\Roadiz\CoreBundle\Security\LoginLink\LoginLinkSenderInterface;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
Expand All @@ -22,7 +23,8 @@ public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TranslatorInterface $translator,
private readonly EmailManagerFactory $emailManagerFactory,
private readonly LoggerInterface $logger
private readonly LoggerInterface $logger,
private readonly LoginLinkSenderInterface $loginLinkSender
) {
}

Expand Down Expand Up @@ -95,38 +97,16 @@ public function sendPasswordResetLink(
}
}

/**
* @deprecated Use LoginLinkSenderInterface::sendLoginLink instead
*/
public function sendLoginLink(
UserInterface $user,
LoginLinkDetails $loginLinkDetails,
string $htmlTemplate = '@RoadizCore/email/users/login_link_email.html.twig',
string $txtTemplate = '@RoadizCore/email/users/login_link_email.txt.twig'
): void {
if (!(($user instanceof User) && $user->isEnabled())) {
throw new \InvalidArgumentException('User must be enabled to send a login link.');
}

$emailManager = $this->emailManagerFactory->create();
$emailContact = $this->getContactEmail();
$siteName = $this->getSiteName();

$emailManager->setAssignation([
'loginLink' => $loginLinkDetails->getUrl(),
'expiresAt' => $loginLinkDetails->getExpiresAt(),
'user' => $user,
'site' => $siteName,
'mailContact' => $emailContact,
]);
$emailManager->setEmailTemplate($htmlTemplate);
$emailManager->setEmailPlainTextTemplate($txtTemplate);
$emailManager->setSubject($this->translator->trans(
'login_link.request'
));

$emailManager->setReceiver($user->getEmail());
$emailManager->setSender([$emailContact => $siteName]);

// Send the message
$emailManager->send();
$this->loginLinkSender->sendLoginLink($user, $loginLinkDetails);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions lib/RoadizRozierBundle/src/Controller/SecurityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Psr\Log\LoggerInterface;
use RZ\Roadiz\CoreBundle\Bag\Settings;
use RZ\Roadiz\CoreBundle\Repository\UserRepository;
use RZ\Roadiz\CoreBundle\Security\User\UserViewer;
use RZ\Roadiz\CoreBundle\Security\LoginLink\LoginLinkSenderInterface;
use RZ\Roadiz\OpenId\Exception\DiscoveryNotAvailableException;
use RZ\Roadiz\OpenId\OAuth2LinkGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand All @@ -27,7 +27,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly Settings $settingsBag,
private readonly RozierServiceRegistry $rozierServiceRegistry,
private readonly UserViewer $userViewer
private readonly LoginLinkSenderInterface $loginLinkSender
) {
}

Expand Down Expand Up @@ -91,7 +91,7 @@ public function requestLoginLink(
// create a login link for $user this returns an instance
// of LoginLinkDetails
$loginLinkDetails = $loginLinkHandler->createLoginLink($user, $request);
$this->userViewer->sendLoginLink($user, $loginLinkDetails);
$this->loginLinkSender->sendLoginLink($user, $loginLinkDetails);

return $this->redirectToRoute('roadiz_rozier_login_link_sent');
}
Expand Down
31 changes: 31 additions & 0 deletions lib/RoadizUserBundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,37 @@ nelmio_cors:
expose_headers: ['Link', 'Www-Authenticate']
```

## Passwordless user creation and authentication


```yaml
# config/routes.yaml
public_login_link_check:
path: /api/users/login_link_check
methods: [POST]
```

```yaml
# config/packages/security.yaml
# https://symfony.com/bundles/LexikJWTAuthenticationBundle/current/8-jwt-user-provider.html#symfony-5-3-and-higher
api:
pattern: ^/api
stateless: true
# We need to use all_users provider to be able to use Roadiz User provider
# during the login_link authentication process
provider: all_users
jwt: ~
login_link:
check_route: public_login_link_check
check_post_only: true
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
signature_properties: [ 'email' ]
# lifetime in seconds
lifetime: 600
max_uses: 3
```


## Maintenance commands

Expand Down
6 changes: 6 additions & 0 deletions lib/RoadizUserBundle/config/api_resources/user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ RZ\Roadiz\CoreBundle\Entity\User:
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
parameters:
Expand Down
1 change: 1 addition & 0 deletions lib/RoadizUserBundle/config/packages/roadiz_user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ roadiz_user:
# Define user validation token expiring time in seconds.
user_validation_expires_in: '%env(int:USER_VALIDATION_EXPIRES_IN)%'
public_user_role_name: ROLE_PUBLIC_USER
passwordless_user_role_name: ROLE_PASSWORDLESS_USER
email_validated_role_name: ROLE_EMAIL_VALIDATED
1 change: 1 addition & 0 deletions lib/RoadizUserBundle/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
$passwordResetExpiresIn: '%roadiz_user.password_reset_expires_in%'
$userValidationExpiresIn: '%roadiz_user.user_validation_expires_in%'
$publicUserRoleName: '%roadiz_user.public_user_role_name%'
$passwordlessUserRoleName: '%roadiz_user.passwordless_user_role_name%'
$emailValidatedRoleName: '%roadiz_user.email_validated_role_name%'
$persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'

Expand Down
Loading

0 comments on commit c86889d

Please sign in to comment.