Skip to content

Commit

Permalink
feat: Added backoffice login-link authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
ambroisemaupate committed Aug 2, 2024
1 parent 986fa15 commit 2a5efeb
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 11 deletions.
8 changes: 8 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions lib/RoadizCoreBundle/config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\CoreBundle\Security\Authentication;

use Doctrine\Persistence\ManagerRegistry;
use RZ\Roadiz\CoreBundle\Entity\User;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;

final class BackofficeAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ManagerRegistry $managerRegistry
) {
}
/**
* @inheritDoc
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response
{
$user = $token->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'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
32 changes: 32 additions & 0 deletions lib/RoadizCoreBundle/src/Security/User/UserViewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
42 changes: 40 additions & 2 deletions lib/RoadizCoreBundle/translations/core/messages.en.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,52 @@
</trans-unit>
<trans-unit xml:space="preserve" id="attributes.defaultRealm.placeholder">
<source>attributes.defaultRealm.placeholder</source>

<target state="translated">-- No default realm --</target>
<note>Default text when no realm is attached to an attribute</note></trans-unit>
<trans-unit xml:space="preserve" id="attributeValue.realm.placeholder">
<source>attributeValue.realm.placeholder</source>

<target state="translated">-- No realm --</target>
<note>Default text when no realm is attached to an attribute-value</note></trans-unit>


<trans-unit xml:space="preserve" id="login_link">
<source>login_link</source>
<target state="final">Login link</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent">
<source>login_link_sent</source>
<target state="final">A login link was sent</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent_if_email_exists.check_inbox">
<source>login_link_sent_if_email_exists.check_inbox</source>
<target state="final">A login link was sent to your email address if your account is valid. Check your inbox.</target>
</trans-unit>
<trans-unit xml:space="preserve" id="request_login_link">
<source>request_login_link</source>
<target state="final">Request a login link</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link.request">
<source>login_link.request</source>
<target state="final">Your login link</target>
</trans-unit>
<trans-unit xml:space="preserve" id="you.asked.for.a.login_link.on.site">
<source>you.asked.for.a.login_link.on.site</source>
<target state="final">You requested a login link for website: %site%</target>
</trans-unit>
<trans-unit xml:space="preserve" id="click_on_following_link_to_login">
<source>click_on_following_link_to_login</source>
<target state="final">Click on the following link to login automatically.</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_will_expire_at.expiresAt">
<source>login_link_will_expire_at.expiresAt</source>
<target state="final">This link will expire on %expiresAt%</target>
</trans-unit>
<trans-unit xml:space="preserve" id="classic.login_password">
<source>classic.login_password</source>
<target state="final">Password login</target>
</trans-unit>
</body>
</file>
</xliff>
41 changes: 39 additions & 2 deletions lib/RoadizCoreBundle/translations/core/messages.fr.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,51 @@
</trans-unit>
<trans-unit xml:space="preserve" id="attributes.defaultRealm.placeholder" approved="yes">
<source>attributes.defaultRealm.placeholder</source>

<target state="final">-- Aucun domaine sécurisé par défaut --</target>
<note>Default text when no realm is attached to an attribute</note></trans-unit>
<trans-unit xml:space="preserve" id="attributeValue.realm.placeholder" approved="yes">
<source>attributeValue.realm.placeholder</source>

<target state="final">-- Aucun domaine sécurisé --</target>
<note>Default text when no realm is attached to an attribute-value</note></trans-unit>

<trans-unit xml:space="preserve" id="login_link">
<source>login_link</source>
<target state="final">Lien de connexion</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent">
<source>login_link_sent</source>
<target state="final">Un lien de connexion vous a été envoyé</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent_if_email_exists.check_inbox">
<source>login_link_sent_if_email_exists.check_inbox</source>
<target state="final">Un lien de connexion vous a été envoyé par email si votre compte est valide, veuillez vérifier votre boîte de réception.</target>
</trans-unit>
<trans-unit xml:space="preserve" id="request_login_link">
<source>request_login_link</source>
<target state="final">Demander un lien de connexion</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link.request">
<source>login_link.request</source>
<target state="final">Votre lien de connexion</target>
</trans-unit>
<trans-unit xml:space="preserve" id="you.asked.for.a.login_link.on.site">
<source>you.asked.for.a.login_link.on.site</source>
<target state="final">Vous avez fait une demande de connexion sur le site : %site%</target>
</trans-unit>
<trans-unit xml:space="preserve" id="click_on_following_link_to_login">
<source>click_on_following_link_to_login</source>
<target state="final">Cliquez sur le lien suivant pour vous connecter automatiquement.</target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_will_expire_at.expiresAt">
<source>login_link_will_expire_at.expiresAt</source>
<target state="final">Ce lien expirera le %expiresAt%</target>
</trans-unit>
<trans-unit xml:space="preserve" id="classic.login_password">
<source>classic.login_password</source>
<target state="final">Se connecter avec mot de passe</target>
</trans-unit>
</body>
</file>
</xliff>
38 changes: 38 additions & 0 deletions lib/RoadizCoreBundle/translations/core/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,44 @@
<note>Default text when no realm is attached to an attribute-value</note>
<target></target>
</trans-unit>

<trans-unit xml:space="preserve" id="login_link">
<source>login_link</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent">
<source>login_link_sent</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_sent_if_email_exists.check_inbox">
<source>login_link_sent_if_email_exists.check_inbox</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="request_login_link">
<source>request_login_link</source>
<note>Button label to request a login link</note>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link.request">
<source>login_link.request</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="you.asked.for.a.login_link.on.site">
<source>you.asked.for.a.login_link.on.%site%</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="click_on_following_link_to_login">
<source>click_on_following_link_to_login</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="login_link_will_expire_at.expiresAt">
<source>login_link_will_expire_at.%expiresAt%</source>
<target></target>
</trans-unit>
<trans-unit xml:space="preserve" id="classic.login_password">
<source>classic.login_password</source>
<target></target>
</trans-unit>
</body>
</file>
</xliff>
13 changes: 13 additions & 0 deletions lib/RoadizRozierBundle/config/routing/login.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 46 additions & 2 deletions lib/RoadizRozierBundle/src/Controller/SecurityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@

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;
use Symfony\Component\HttpFoundation\Request;
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
Expand All @@ -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()) {
Expand Down Expand Up @@ -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');
}
}
Loading

0 comments on commit 2a5efeb

Please sign in to comment.