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'),
+ }) }}
+
+ {% trans %}login{% endtrans %}
+
+
+
+
+
+
+
+{% 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
+