diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8324dc43..b314b29a 100644 --- a/config/packages/security.yaml +++ b/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/config/services.yaml b/config/services.yaml index 385bac36..5d0d030b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -61,7 +61,6 @@ services: - '../src/Traits/' - '../src/Kernel.php' - '../src/Tests/' - - '../src/DataCollector/' - '../src/Event/' - '../src/Model/' - '../src/ListManager/' @@ -406,11 +405,6 @@ services: RZ\Roadiz\CoreBundle\SearchEngine\ClientRegistry: arguments: ['@service_container'] - RZ\Roadiz\CoreBundle\SearchEngine\SolariumLogger: - tags: - - { name: data_collector, template: '@RoadizCore/DataCollector/solarium.html.twig', id: 'solarium' } - - { name: monolog.logger, channel: solr } - RZ\Roadiz\CoreBundle\SearchEngine\Indexer\IndexerFactory: arguments: ['@service_container'] diff --git a/src/Api/Filter/ArchiveFilter.php b/src/Api/Filter/ArchiveFilter.php index d4495c3f..7b58e2f5 100644 --- a/src/Api/Filter/ArchiveFilter.php +++ b/src/Api/Filter/ArchiveFilter.php @@ -8,7 +8,7 @@ use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Exception\FilterValidationException; +use ApiPlatform\Exception\FilterValidationException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; diff --git a/src/Api/Filter/IntersectionFilter.php b/src/Api/Filter/IntersectionFilter.php index 7f3c2f6d..699e1edc 100644 --- a/src/Api/Filter/IntersectionFilter.php +++ b/src/Api/Filter/IntersectionFilter.php @@ -6,7 +6,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; -use ApiPlatform\Metadata\Exception\FilterValidationException; +use ApiPlatform\Exception\FilterValidationException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; diff --git a/src/Api/Filter/LocaleFilter.php b/src/Api/Filter/LocaleFilter.php index 259bc62f..aaefd4a5 100644 --- a/src/Api/Filter/LocaleFilter.php +++ b/src/Api/Filter/LocaleFilter.php @@ -5,7 +5,7 @@ namespace RZ\Roadiz\CoreBundle\Api\Filter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Exception\FilterValidationException; +use ApiPlatform\Exception\FilterValidationException; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/SearchEngine/SolariumLogger.php b/src/DataCollector/SolariumLogger.php similarity index 93% rename from src/SearchEngine/SolariumLogger.php rename to src/DataCollector/SolariumLogger.php index fb0f7e19..8de222d6 100644 --- a/src/SearchEngine/SolariumLogger.php +++ b/src/DataCollector/SolariumLogger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace RZ\Roadiz\CoreBundle\SearchEngine; +namespace RZ\Roadiz\CoreBundle\DataCollector; use Psr\Log\LoggerInterface; use Solarium\Core\Client\Endpoint as SolariumEndpoint; @@ -13,6 +13,7 @@ use Solarium\Core\Event\PreExecuteRequest as SolariumPreExecuteRequestEvent; use Solarium\Core\Plugin\AbstractPlugin as SolariumPlugin; use Symfony\Bundle\FrameworkBundle\DataCollector\TemplateAwareDataCollectorInterface; +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request as HttpRequest; use Symfony\Component\HttpFoundation\Response as HttpResponse; @@ -22,6 +23,14 @@ /** * @see https://github.com/nelmio/NelmioSolariumBundle */ +#[AutoconfigureTag('data_collector', [ + 'template' => '@RoadizCore/DataCollector/solarium.html.twig', + # must match the value returned by the getName() method + 'id' => 'solarium', +])] +#[AutoconfigureTag('monolog.logger', [ + 'channel' => 'solr', +])] final class SolariumLogger extends SolariumPlugin implements DataCollectorInterface, \Serializable, EventSubscriberInterface, TemplateAwareDataCollectorInterface { private array $data = []; diff --git a/src/DependencyInjection/RoadizCoreExtension.php b/src/DependencyInjection/RoadizCoreExtension.php index dce12f58..2aeab2b7 100644 --- a/src/DependencyInjection/RoadizCoreExtension.php +++ b/src/DependencyInjection/RoadizCoreExtension.php @@ -9,6 +9,7 @@ use RZ\Roadiz\CoreBundle\Cache\CloudflareProxyCache; use RZ\Roadiz\CoreBundle\Cache\ReverseProxyCache; use RZ\Roadiz\CoreBundle\Cache\ReverseProxyCacheLocator; +use RZ\Roadiz\CoreBundle\DataCollector\SolariumLogger; use RZ\Roadiz\CoreBundle\Entity\CustomForm; use RZ\Roadiz\CoreBundle\Entity\Document; use RZ\Roadiz\CoreBundle\Entity\Node; @@ -18,7 +19,6 @@ use RZ\Roadiz\CoreBundle\Entity\NodeType; use RZ\Roadiz\CoreBundle\Entity\Translation; use RZ\Roadiz\CoreBundle\Repository\NodesSourcesRepository; -use RZ\Roadiz\CoreBundle\SearchEngine\SolariumLogger; use RZ\Roadiz\CoreBundle\Webhook\Message\GenericJsonPostMessage; use RZ\Roadiz\CoreBundle\Webhook\Message\GitlabPipelineTriggerMessage; use RZ\Roadiz\CoreBundle\Webhook\Message\NetlifyBuildHookMessage; diff --git a/src/Security/Authentication/BackofficeAuthenticationSuccessHandler.php b/src/Security/Authentication/BackofficeAuthenticationSuccessHandler.php new file mode 100644 index 00000000..01844589 --- /dev/null +++ b/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/src/Security/Authentication/RoadizAuthenticator.php b/src/Security/Authentication/RoadizAuthenticator.php index 4295a651..5d58b3cc 100644 --- a/src/Security/Authentication/RoadizAuthenticator.php +++ b/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/src/Security/User/UserViewer.php b/src/Security/User/UserViewer.php index 673c7f28..b0c4f6bc 100644 --- a/src/Security/User/UserViewer.php +++ b/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,40 @@ 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 { + 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(); + } + /** * @return string */ diff --git a/src/Serializer/TranslationAwareContextBuilder.php b/src/Serializer/TranslationAwareContextBuilder.php index 66867a64..8804fead 100644 --- a/src/Serializer/TranslationAwareContextBuilder.php +++ b/src/Serializer/TranslationAwareContextBuilder.php @@ -27,6 +27,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + // @phpstan-ignore-next-line return $context; } diff --git a/templates/email/users/login_link_email.html.twig b/templates/email/users/login_link_email.html.twig new file mode 100644 index 00000000..26c0b42c --- /dev/null +++ b/templates/email/users/login_link_email.html.twig @@ -0,0 +1,39 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{% trans %}login_link.request{% endtrans %}{% endblock %} + +{% block content_table %} + + + + + + + +
+ {% block content_title %} +

{% trans %}login_link.request{% endtrans %}

+ {% endblock %} +
+ + + + + + + +
+ {% block content_subtitle %} +

{{ 'you.asked.for.a.login_link.on.site'|trans({'%site%':site})|escape }}

+ {% endblock %} +
+

{% trans %}click_on_following_link_to_login{% endtrans %}

+

{{ 'login_link_will_expire_at.expiresAt'|trans({ + '%expiresAt%': expiresAt|format_datetime('short', 'short'), + }) }}

+

+ +

+
+
+{% endblock %} diff --git a/templates/email/users/login_link_email.txt.twig b/templates/email/users/login_link_email.txt.twig new file mode 100644 index 00000000..80f86c85 --- /dev/null +++ b/templates/email/users/login_link_email.txt.twig @@ -0,0 +1,16 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{% trans %}login_link.request{% endtrans %}{% endblock %} +{% block content_subtitle %}{{ 'you.asked.for.a.login_link.on.site'|trans({'%site%':site})|escape }}{% endblock %} + +{% block content_table %} +{% trans %}click_on_following_link_to_login{% endtrans %} +{{ 'login_link_will_expire_at.expiresAt'|trans({ + '%expiresAt%': expiresAt|format_datetime('short', 'short'), +}) }} + +--- +{{ loginLink }} +--- + +{% endblock %} diff --git a/translations/core/messages.en.xlf b/translations/core/messages.en.xlf index 760ae856..5fc5f68a 100644 --- a/translations/core/messages.en.xlf +++ b/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/translations/core/messages.fr.xlf b/translations/core/messages.fr.xlf index 0210b7a4..c51a634b 100644 --- a/translations/core/messages.fr.xlf +++ b/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/translations/core/messages.xlf b/translations/core/messages.xlf index ecf881ff..3661554f 100644 --- a/translations/core/messages.xlf +++ b/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 + +