diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 80d1a020..0a526c8d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -82,6 +82,14 @@ security: secret: '%kernel.secret%' lifetime: 604800 # 1 week in seconds path: / + login_link: + check_route: login_link_check + signature_properties: [ 'id', 'email' ] + # lifetime in seconds + lifetime: 300 + # only allow the link to be used 3 times + max_uses: 3 + success_handler: RZ\Roadiz\CoreBundle\Security\Authentication\BackofficeAuthenticationSuccessHandler login_throttling: max_attempts: 3 logout: diff --git a/lib/RoadizCoreBundle/config/packages/security.yaml b/lib/RoadizCoreBundle/config/packages/security.yaml index 8324dc43..b314b29a 100644 --- a/lib/RoadizCoreBundle/config/packages/security.yaml +++ b/lib/RoadizCoreBundle/config/packages/security.yaml @@ -75,6 +75,16 @@ security: secret: '%kernel.secret%' lifetime: 604800 # 1 week in seconds path: / + # Enable login-link feature + # https://symfony.com/doc/current/security/login_link.html + login_link: + check_route: login_link_check + signature_properties: [ 'id', 'email' ] + # lifetime in seconds + lifetime: 300 + # only allow the link to be used 3 times + max_uses: 3 + success_handler: RZ\Roadiz\CoreBundle\Security\Authentication\BackofficeAuthenticationSuccessHandler login_throttling: max_attempts: 3 logout: diff --git a/lib/RoadizCoreBundle/src/Security/Authentication/BackofficeAuthenticationSuccessHandler.php b/lib/RoadizCoreBundle/src/Security/Authentication/BackofficeAuthenticationSuccessHandler.php new file mode 100644 index 00000000..01844589 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authentication/BackofficeAuthenticationSuccessHandler.php @@ -0,0 +1,38 @@ +getUser(); + + if ($user instanceof User) { + $user->setLastLogin(new \DateTime('now')); + $manager = $this->managerRegistry->getManagerForClass(User::class); + $manager?->flush(); + } + + return new RedirectResponse($this->urlGenerator->generate('adminHomePage')); + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php b/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php index 4295a651..5d58b3cc 100644 --- a/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php +++ b/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php @@ -62,9 +62,7 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, if ($user instanceof User) { $user->setLastLogin(new \DateTime('now')); $manager = $this->managerRegistry->getManagerForClass(User::class); - if (null !== $manager) { - $manager->flush(); - } + $manager?->flush(); } if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { diff --git a/lib/RoadizCoreBundle/src/Security/User/UserViewer.php b/lib/RoadizCoreBundle/src/Security/User/UserViewer.php index 673c7f28..ee9cdbd0 100644 --- a/lib/RoadizCoreBundle/src/Security/User/UserViewer.php +++ b/lib/RoadizCoreBundle/src/Security/User/UserViewer.php @@ -11,6 +11,8 @@ use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\LoginLink\LoginLinkDetails; use Symfony\Contracts\Translation\TranslatorInterface; final class UserViewer @@ -93,6 +95,36 @@ public function sendPasswordResetLink( } } + 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 { + $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(); + } + /** * @return string */ diff --git a/lib/RoadizCoreBundle/translations/core/messages.en.xlf b/lib/RoadizCoreBundle/translations/core/messages.en.xlf index 760ae856..5fc5f68a 100644 --- a/lib/RoadizCoreBundle/translations/core/messages.en.xlf +++ b/lib/RoadizCoreBundle/translations/core/messages.en.xlf @@ -167,14 +167,52 @@ attributes.defaultRealm.placeholder - + -- No default realm -- Default text when no realm is attached to an attribute attributeValue.realm.placeholder - + -- No realm -- Default text when no realm is attached to an attribute-value + + + + login_link + Login link + + + login_link_sent + A login link was sent + + + login_link_sent_if_email_exists.check_inbox + A login link was sent to your email address if your account is valid. Check your inbox. + + + request_login_link + Request a login link + + + login_link.request + Your login link + + + you.asked.for.a.login_link.on.site + You requested a login link for website: %site% + + + click_on_following_link_to_login + Click on the following link to login automatically. + + + login_link_will_expire_at.expiresAt + This link will expire on %expiresAt% + + + classic.login_password + Password login + diff --git a/lib/RoadizCoreBundle/translations/core/messages.fr.xlf b/lib/RoadizCoreBundle/translations/core/messages.fr.xlf index 0210b7a4..c51a634b 100644 --- a/lib/RoadizCoreBundle/translations/core/messages.fr.xlf +++ b/lib/RoadizCoreBundle/translations/core/messages.fr.xlf @@ -167,14 +167,51 @@ attributes.defaultRealm.placeholder - + -- Aucun domaine sécurisé par défaut -- Default text when no realm is attached to an attribute attributeValue.realm.placeholder - + -- Aucun domaine sécurisé -- Default text when no realm is attached to an attribute-value + + + login_link + Lien de connexion + + + login_link_sent + Un lien de connexion vous a été envoyé + + + login_link_sent_if_email_exists.check_inbox + Un lien de connexion vous a été envoyé par email si votre compte est valide, veuillez vérifier votre boîte de réception. + + + request_login_link + Demander un lien de connexion + + + login_link.request + Votre lien de connexion + + + you.asked.for.a.login_link.on.site + Vous avez fait une demande de connexion sur le site : %site% + + + click_on_following_link_to_login + Cliquez sur le lien suivant pour vous connecter automatiquement. + + + login_link_will_expire_at.expiresAt + Ce lien expirera le %expiresAt% + + + classic.login_password + Se connecter avec mot de passe + diff --git a/lib/RoadizCoreBundle/translations/core/messages.xlf b/lib/RoadizCoreBundle/translations/core/messages.xlf index ecf881ff..3661554f 100644 --- a/lib/RoadizCoreBundle/translations/core/messages.xlf +++ b/lib/RoadizCoreBundle/translations/core/messages.xlf @@ -184,6 +184,44 @@ Default text when no realm is attached to an attribute-value + + + login_link + + + + login_link_sent + + + + login_link_sent_if_email_exists.check_inbox + + + + request_login_link + Button label to request a login link + + + + login_link.request + + + + you.asked.for.a.login_link.on.%site% + + + + click_on_following_link_to_login + + + + login_link_will_expire_at.%expiresAt% + + + + classic.login_password + + diff --git a/lib/RoadizRozierBundle/config/routing/login.yml b/lib/RoadizRozierBundle/config/routing/login.yml index d92856c1..c7025456 100644 --- a/lib/RoadizRozierBundle/config/routing/login.yml +++ b/lib/RoadizRozierBundle/config/routing/login.yml @@ -21,3 +21,16 @@ loginPage: logoutPage: path: /logout controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::logout + +roadiz_rozier_login_link: + path: /login_link + controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::requestLoginLink + +roadiz_rozier_login_link_sent: + methods: [GET] + path: /login_link_sent + controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::loginLinkSent + +login_link_check: + path: /login_link_check + controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::check diff --git a/lib/RoadizRozierBundle/src/Controller/SecurityController.php b/lib/RoadizRozierBundle/src/Controller/SecurityController.php index e354ce2e..42f2d8ca 100644 --- a/lib/RoadizRozierBundle/src/Controller/SecurityController.php +++ b/lib/RoadizRozierBundle/src/Controller/SecurityController.php @@ -6,6 +6,8 @@ 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\OpenId\Exception\DiscoveryNotAvailableException; use RZ\Roadiz\OpenId\OAuth2LinkGenerator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,7 +15,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; use Themes\Rozier\RozierServiceRegistry; class SecurityController extends AbstractController @@ -22,11 +26,12 @@ public function __construct( private readonly OAuth2LinkGenerator $oAuth2LinkGenerator, private readonly LoggerInterface $logger, private readonly Settings $settingsBag, - private readonly RozierServiceRegistry $rozierServiceRegistry + private readonly RozierServiceRegistry $rozierServiceRegistry, + private readonly UserViewer $userViewer ) { } - #[Route(path: '/rz-admin/login', name: 'roadiz_rozier_login')] + #[Route(path: '/rz-admin/login', name: 'loginPage')] public function login(Request $request, AuthenticationUtils $authenticationUtils): Response { if ($this->getUser()) { @@ -62,9 +67,48 @@ public function login(Request $request, AuthenticationUtils $authenticationUtils return $this->render('@RoadizRozier/security/login.html.twig', $assignation); } + #[Route('/rz-admin/login_link', name: 'roadiz_rozier_login_link')] + public function requestLoginLink( + LoginLinkHandlerInterface $loginLinkHandler, + UserRepository $userRepository, + Request $request + ): Response { + // check if form is submitted + if ($request->isMethod('POST')) { + // load the user in some way (e.g. using the form input) + $email = $request->getPayload()->get('email'); + $user = $userRepository->findOneBy(['email' => $email]); + + if (!($user instanceof UserInterface)) { + // Do not reveal whether a user account exists or not + return $this->redirectToRoute('roadiz_rozier_login_link_sent'); + } + // create a login link for $user this returns an instance + // of LoginLinkDetails + $loginLinkDetails = $loginLinkHandler->createLoginLink($user, $request); + $this->userViewer->sendLoginLink($user, $loginLinkDetails); + + return $this->redirectToRoute('roadiz_rozier_login_link_sent'); + } + + // if it's not submitted, render the form to request the "login link" + return $this->render('@RoadizRozier/security/request_login_link.html.twig'); + } + + #[Route('/rz-admin/login_link_sent', name: 'roadiz_rozier_login_link_sent')] + public function loginLinkSent(): Response { + return $this->render('@RoadizRozier/security/login_link_sent.html.twig'); + } + #[Route(path: '/rz-admin/logout', name: 'roadiz_rozier_logout')] public function logout(): void { throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); } + + #[Route('/rz-admin/login_link_check', name: 'login_link_check')] + public function check(): never + { + throw new \LogicException('This code should never be reached'); + } } diff --git a/lib/RoadizRozierBundle/templates/security/login.html.twig b/lib/RoadizRozierBundle/templates/security/login.html.twig index cfa32867..e08de3c1 100644 --- a/lib/RoadizRozierBundle/templates/security/login.html.twig +++ b/lib/RoadizRozierBundle/templates/security/login.html.twig @@ -19,7 +19,6 @@


{% endif %} -
@@ -30,7 +29,7 @@ value="{{ last_username }}" name="username" id="_username" - autocomplete="username" + autocomplete="username email" placeholder="{% trans %}username{% endtrans %}" class="form-control" required @@ -59,6 +58,8 @@
+ {% trans %}request_login_link{% endtrans %}
{% endblock %}