Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\UserBundle\Security\Authentication;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
class AuthenticationHandler implements AuthenticationSuccessHandlerInterface, AuthenticationFailureHandlerInterface
{
public function __construct(
private RouterInterface $router,
) {
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response
{
// Remove post_logout if set
$request->getSession()->remove('post_logout');
$format = $request->request->get('format');
if ('json' == $format) {
$array = ['success' => true];
$response = new Response(json_encode($array));
$response->headers->set('Content-Type', 'application/json');
return $response;
} else {
$redirectUrl = $request->getSession()->get('_security.main.target_path', $this->router->generate('mautic_dashboard_index'));
return new RedirectResponse($redirectUrl);
}
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
// Remove post_logout if set
$request->getSession()->remove('post_logout');
$format = $request->request->get('format');
if ('json' == $format) {
$array = ['success' => false, 'message' => $exception->getMessage()];
$response = new Response(json_encode($array));
$response->headers->set('Content-Type', 'application/json');
return $response;
} else {
$request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse($this->router->generate('login'));
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authentication\Token\Permissions;
use Doctrine\ORM\EntityManagerInterface;
use FOS\OAuthServerBundle\Model\TokenInterface as OAuthTokenInterface;
use FOS\OAuthServerBundle\Security\Authenticator\Token\OAuthToken;
use Mautic\ApiBundle\Entity\oAuth2\AccessToken;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\UserBundle\Entity\PermissionRepository;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
class TokenPermissions
{
public function __construct(private TokenStorageInterface $tokenStorage, private PermissionRepository $permissionRepository, private EntityManagerInterface $entityManager)
{
}
/**
* Set the active permissions on the current user.
*/
public function setActivePermissionsOnAuthToken(TokenInterface|OAuthTokenInterface|null $token = null): ?UserInterface
{
if (null === $token) {
$token = $this->tokenStorage->getToken();
}
if (null === $token) {
return null;
}
$user = $token->getUser();
\assert(null === $user || $user instanceof User);
// If no user is associated with a token, it's a client credentials grant type. Handle accordingly.
if (null === $user && ($token instanceof OAuthToken || $token instanceof OAuthTokenInterface)) {
$user = $this->assignRoleFromToken($token->getToken());
}
if (null !== $user) {
$this->setPermissionsOnUser($user);
}
if (null === $user) {
throw new \RuntimeException('The user should be either already set in the token, or come from assignRoleFromToken.');
}
$token->setUser($user);
if ($token instanceof TokenInterface) {
$this->tokenStorage->setToken($token);
}
return $user;
}
/**
* Handle permission for Client Credential grant type.
*/
private function assignRoleFromToken(string $tokenIdentifier): User
{
/** @var AccessToken|null $accessToken assert ill yield phpstan error. */
$accessToken = $this->entityManager->getRepository(AccessToken::class)->findOneBy(['token' => $tokenIdentifier]);
if (null === $accessToken) {
throw new UserNotFoundException('API access token not found.');
}
$client = $accessToken->getClient();
if (!$client instanceof Client) {
// There are no tests for this part, so an exception will reveal any inconsistencies earlier.
throw new \RuntimeException('The client is not a valid API client.');
}
$role = $client->getRole();
// Create a pseudo user and assign the role
$user = new User();
$user->setRole($role);
// Set for the audit log and the entity's "created by user" metadata which takes the first and last name
$user->setFirstName($client->getName());
$user->setLastName(sprintf('[%s]', $client->getId()));
$user->setUsername($user->getName());
defined('MAUTIC_AUDITLOG_USER') || define('MAUTIC_AUDITLOG_USER', $user->getName());
return $user;
}
private function setPermissionsOnUser(User $user): void
{
if (!$user->isAdmin() && (null === $user->getActivePermissions() || [] === $user->getActivePermissions())) {
$activePermissions = $this->permissionRepository->getPermissionsByRole($user->getRole());
$user->setActivePermissions($activePermissions);
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\UserBundle\Security\Authentication\Token;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
use Symfony\Component\Security\Core\User\UserInterface;
class PluginToken extends AbstractToken
{
private ?string $providerKey;
/**
* @param UserInterface|string|null $user
* @param array<string> $roles
*/
public function __construct(
?string $providerKey,
private ?string $authenticatingService = null,
$user = null,
private string $credentials = '',
array $roles = [],
private ?Response $response = null,
) {
parent::__construct($roles);
if ('' === $providerKey) {
throw new \InvalidArgumentException('$providerKey must not be empty.');
}
if (is_string($user)) {
$user = null;
}
if (null !== $user) {
$this->setUser($user);
}
$this->providerKey = $providerKey;
}
public function getCredentials(): string
{
return $this->credentials;
}
public function getProviderKey(): ?string
{
return $this->providerKey;
}
public function getAuthenticatingService(): ?string
{
return $this->authenticatingService;
}
public function getResponse(): ?Response
{
return $this->response;
}
/**
* @return array<int, mixed>
*/
public function __serialize(): array
{
return array_merge([$this->authenticatingService, $this->credentials, $this->providerKey, parent::__serialize()]);
}
/**
* @param array<int, mixed> $data
*/
public function __unserialize(array $data): void
{
[$this->authenticatingService, $this->credentials, $this->providerKey, $parentArray] = $data;
parent::__unserialize($parentArray);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use OAuth2\OAuth2ServerException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class Oauth2Authenticator extends \FOS\OAuthServerBundle\Security\Authenticator\Oauth2Authenticator
{
public function supports(Request $request): ?bool
{
// needed until the oAuth2 library will not be updated to 4.0.5
return null !== $this->serverService->getBearerToken($request);
}
/**
* A BC compatible response.
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$previous = $exception->getPrevious();
if ($previous instanceof OAuth2ServerException) {
return $previous->getHttpResponse();
}
return parent::onAuthenticationFailure($request, $exception);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator\Passport\Badge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class PasswordStrengthBadge implements BadgeInterface
{
private bool $resolved = false;
public function __construct(private ?string $presentedPassword)
{
}
public function getPresentedPassword(): ?string
{
return $this->presentedPassword;
}
public function setResolved(): void
{
$this->resolved = true;
}
public function isResolved(): bool
{
return $this->resolved;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator\Passport\Badge;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class PluginBadge implements BadgeInterface
{
public function __construct(private ?TokenInterface $preAuthenticatedToken, private ?Response $pluginResponse, private ?string $authenticatingService)
{
}
public function getPreAuthenticatedToken(): ?TokenInterface
{
return $this->preAuthenticatedToken;
}
public function getPluginResponse(): ?Response
{
return $this->pluginResponse;
}
public function getAuthenticatingService(): ?string
{
return $this->authenticatingService;
}
public function isResolved(): bool
{
return null !== $this->preAuthenticatedToken || null !== $this->pluginResponse;
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Event\AuthenticationEvent;
use Mautic\UserBundle\Security\Authentication\AuthenticationHandler;
use Mautic\UserBundle\Security\Authentication\Token\Permissions\TokenPermissions;
use Mautic\UserBundle\Security\Authentication\Token\PluginToken;
use Mautic\UserBundle\Security\Authenticator\Passport\Badge\PluginBadge;
use Mautic\UserBundle\UserEvents;
use OAuth2\OAuth2;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\LazyResponseException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
final class PluginAuthenticator extends AbstractAuthenticator
{
public function __construct(private TokenPermissions $tokenPermissions, private EventDispatcherInterface $dispatcher, private IntegrationHelper $integrationHelper, private UserProviderInterface $userProvider, private AuthenticationHandler $authenticationHandler, private OAuth2 $oAuth2, private LoggerInterface $logger, private string $firewallName)
{
}
public function supports(Request $request): ?bool
{
// Pass to oAuth2 if the token is present, but try to execute in case the Request does not look like oAuth2.
return null === $this->oAuth2->getBearerToken($request) ? null : false;
}
public function authenticate(Request $request): Passport
{
$authenticatingService = $request->get('integration');
\assert(null === $authenticatingService || is_string($authenticatingService));
$token = new PluginToken($this->firewallName, $authenticatingService);
$user = null;
$response = null;
$authenticated = false;
$authenticatedToken = null;
// Try authenticating with a plugin
if ($this->dispatcher->hasListeners(UserEvents::USER_PRE_AUTHENTICATION)) {
$integrations = $this->integrationHelper->getIntegrationObjects($authenticatingService, ['sso_service'], false, null, true);
$loginCheck = 'mautic_sso_login_check' === $request->attributes->get('_route');
$authEvent = new AuthenticationEvent(
null,
$token,
$this->userProvider,
$request,
$loginCheck,
$authenticatingService,
$integrations
);
$authEvent = $this->dispatcher->dispatch($authEvent, UserEvents::USER_PRE_AUTHENTICATION);
\assert($authEvent instanceof AuthenticationEvent);
if ($authenticated = $authEvent->isAuthenticated()) {
$eventToken = $authEvent->getToken();
$authenticatingService = $authEvent->getAuthenticatingService();
// Return passport with the token set in the event, if the event set a different token.
if ($eventToken !== $token) {
return new SelfValidatingPassport(
new UserBadge($eventToken->getUserIdentifier(), function () use ($eventToken): UserInterface {
return $eventToken->getUser();
}),
[new PluginBadge($eventToken, null, $authenticatingService)]
);
}
// Set the user in the token.
$user = $authEvent->getUser();
if (null === $user) {
throw new \RuntimeException('User must be set in the authenticated token.');
}
$authenticatedToken = $eventToken;
$authenticatedToken->setUser($user);
}
$response = $authEvent->getResponse();
if (!$authenticated && $loginCheck && null === $response) {
// Set an empty JSON response
$response = new JsonResponse([]);
}
}
// The check is intended to catch: Plugin authenticator must be authenticated and have $user. oAuth should have a response.
if (!$user instanceof User && !$authenticated && null === $response) {
throw new AuthenticationException('mautic.user.auth.error.invalidlogin');
}
// Otherwise if the plugin authenticator has a response, then pass it to the Symfony.
if (null === $user && !$authenticated && null !== $response) {
throw new LazyResponseException($response);
}
return new SelfValidatingPassport(
new UserBadge(
$user instanceof User ? $user->getUserIdentifier() : $user,
function (string $userIdentifier) use ($user): UserInterface {
if ($user instanceof User) {
return $user;
}
return $this->userProvider->loadUserByIdentifier($userIdentifier);
}
),
[new PluginBadge($authenticatedToken, $response, $authenticatingService)]
);
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
$pluginBadge = $passport->getBadge(PluginBadge::class);
\assert($pluginBadge instanceof PluginBadge);
$userBadge = $passport->getBadge(UserBadge::class);
\assert($userBadge instanceof UserBadge);
// A custom token has not been set by the plugin, so create a new one. Mainly used for oAuth.
if (null === $token = $pluginBadge->getPreAuthenticatedToken()) {
$user = $userBadge->getUser();
$token = new PluginToken(
$this->firewallName,
$pluginBadge->getAuthenticatingService(),
$user,
($user instanceof User) ? $user->getPassword() : '',
($user instanceof User) ? $user->getRoles() : [],
$pluginBadge->getPluginResponse()
);
}
$this->tokenPermissions->setActivePermissionsOnAuthToken($token);
return $token;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if (!$token instanceof PluginToken) {
// Maybe this need to be replaced with assert, but as no tests cover this, an exception will be noticed earlier.
throw new \RuntimeException('Token is not a PluginToken');
}
if ('api' === $this->firewallName) {
return $token->getResponse();
}
$this->logger->info(sprintf('User "%s" has been authenticated successfully', $token->getUserIdentifier()));
$session = $request->getSession();
$session->remove(SecurityRequestAttributes::AUTHENTICATION_ERROR);
$loginEvent = new InteractiveLoginEvent($request, $token);
$this->dispatcher->dispatch($loginEvent, SecurityEvents::INTERACTIVE_LOGIN);
$response = null;
if (null === $token->getResponse()) {
$response = $this->authenticationHandler->onAuthenticationSuccess($request, $token);
}
return $response;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$this->logger->info(sprintf('Authentication request failed: %s', $exception->getMessage()));
// Gets app/bundles/UserBundle/Security/Firewall/AuthenticationListener.php:74 and till the end of the method referenced.
if (in_array($this->firewallName, ['api', 'v2api'])) {
// Continue with another authentication.
return null;
}
return $this->authenticationHandler->onAuthenticationFailure($request, $exception);
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\Authenticator;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Event\AuthenticationEvent;
use Mautic\UserBundle\Security\Authentication\Token\PluginToken;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
/**
* This is a modified copy of the \Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator
* Replaces \Mautic\UserBundle\Security\Authenticator\FormAuthenticator.
*/
final class SsoAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{
/**
* @var array<mixed>
*/
private array $options;
/**
* @param array<mixed> $options
*/
public function __construct(array $options, private HttpUtils $httpUtils, private UserProviderInterface $userProvider, private AuthenticationSuccessHandlerInterface $successHandler, private AuthenticationFailureHandlerInterface $failureHandler, private IntegrationHelper $integrationHelper, private EventDispatcherInterface $dispatcher)
{
if ([] === $options) {
throw new \RuntimeException('$options parameter is empty. Did you forgot to configure?');
}
$this->options = array_merge([
'username_parameter' => '_username',
'password_parameter' => '_password',
'integration_parameter' => 'integration',
'post_only' => true,
'enable_csrf' => true,
'csrf_parameter' => '_csrf_token',
'csrf_token_id' => 'authenticate',
], $options);
}
public function supports(Request $request): bool
{
if (true === $this->options['post_only'] && !$request->isMethod(Request::METHOD_POST)) {
return false;
}
if (!$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) {
return false;
}
if (true === $this->options['form_only'] && 'form' !== $request->getContentTypeFormat()) {
return false;
}
if (true === $this->options['post_only']) {
return $request->request->has($this->options['integration_parameter']);
}
return $request->query->has($this->options['integration_parameter'])
|| $request->request->has($this->options['integration_parameter']);
}
public function authenticate(Request $request): Passport
{
$credentials = $this->getCredentials($request);
$authenticatingService = $credentials['integration'] ?? null;
$passport = new Passport(
new UserBadge($credentials['username'], function (string $userIdentifier) use ($request, $authenticatingService, $credentials): ?User {
/** @var User|null $user */
$user = null;
try {
$user = $this->userProvider->loadUserByIdentifier($userIdentifier);
} catch (UserNotFoundException) {
// Do nothing. Will try to authenticate by username.
}
// Try authenticating with a plugin
$integrations = $this->integrationHelper->getIntegrationObjects($authenticatingService, ['sso_form'], false, null, true);
$token = new PluginToken(
null,
$authenticatingService,
$userIdentifier,
null !== $user ? ($credentials['password'] ?? null) : '',
null !== $user ? $user->getRoles() : [],
);
$authEvent = new AuthenticationEvent(
$user ?? $userIdentifier,
$token,
$this->userProvider,
$request,
false,
$authenticatingService,
$integrations
);
if ($this->dispatcher->hasListeners(UserEvents::USER_FORM_AUTHENTICATION)) {
$authEvent = $this->dispatcher->dispatch($authEvent, UserEvents::USER_FORM_AUTHENTICATION);
}
if ($authEvent->isAuthenticated()) {
$user = $authEvent->getUser();
// This line is most likely will never happen. Keep it until this is thoroughly tested manually.
if (null !== $user && !$user instanceof User) {
return null;
}
return $user;
}
if ($authEvent->isFailed()) {
throw new AuthenticationException($authEvent->getFailedAuthenticationMessage());
}
if (!$user instanceof User) {
return null;
}
return $user;
}),
new PasswordCredentials($credentials['password'] ?? null),
[new RememberMeBadge()]
);
if ($this->options['enable_csrf']) {
$passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token']));
}
return $passport;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->successHandler->onAuthenticationSuccess($request, $token);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return $this->failureHandler->onAuthenticationFailure($request, $exception);
}
/**
* @return array<string, mixed>
*/
private function getCredentials(Request $request): array
{
$credentials = [];
$credentials['csrf_token'] = $request->get($this->options['csrf_parameter']);
if ($this->options['post_only']) {
$credentials['username'] = $request->request->get($this->options['username_parameter']);
$credentials['password'] = $request->request->get($this->options['password_parameter']) ?? '';
$credentials['integration'] = $request->request->get($this->options['integration_parameter']);
} else {
$credentials['username'] = $request->get($this->options['username_parameter']);
$credentials['password'] = $request->get($this->options['password_parameter']) ?? '';
$credentials['integration'] = $request->get($this->options['integration_parameter']);
}
if (!\is_string($credentials['username'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username'])));
}
$credentials['username'] = trim($credentials['username']);
if (\strlen($credentials['username']) > UserBadge::MAX_USERNAME_LENGTH) {
throw new BadCredentialsException('Invalid username.');
}
if (null !== $credentials['integration'] && !\is_string($credentials['integration'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string or null, "%s" given.', $this->options['integration_parameter'], \gettype($credentials['integration'])));
}
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']);
if (!\is_string($credentials['password'])) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password'])));
}
return $credentials;
}
public function isInteractive(): bool
{
return true;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
final class DummyToken extends AbstractToken
{
public function getCredentials(): string
{
return '';
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\EntryPoint;
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\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class MainEntryPoint implements AuthenticationEntryPointInterface
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private bool $samlEnabled)
{
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
// as per https://docs.mautic.org/en/5.x/authentication/authentication.html#logging-in
// log in always as SAML for all requests.
// todo: task for testers: enable saml, and check if regular login page is available
$route = (string) $request->attributes->get('_route');
if ($this->samlEnabled && 'login' !== $route && 'mautic_user_logincheck' !== $route) {
// As the system doesn't know the IDP of the service, we can spare one redirect,
// and redirect the user straight to discovery.
return new RedirectResponse($this->urlGenerator->generate('lightsaml_sp.discovery'));
}
return new RedirectResponse($this->urlGenerator->generate('login'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\UserBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Mautic\UserBundle\Form\Type\PermissionListType;
use Symfony\Component\Form\FormBuilderInterface;
class UserPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->permissions = [
'profile' => [
'editusername' => 1,
'editemail' => 2,
'editposition' => 4,
'editname' => 8,
'full' => 1024,
],
];
$this->addStandardPermissions('users', false);
$this->addStandardPermissions('roles', false);
}
public function getName(): string
{
return 'user';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('user', 'users', $builder, $data, false);
$this->addStandardFormFields('user', 'roles', $builder, $data, false);
$builder->add(
'user:profile',
PermissionListType::class,
[
'choices' => [
'mautic.user.account.permissions.editname' => 'editname',
'mautic.user.account.permissions.editusername' => 'editusername',
'mautic.user.account.permissions.editemail' => 'editemail',
'mautic.user.account.permissions.editposition' => 'editposition',
'mautic.user.account.permissions.editall' => 'full',
],
'label' => 'mautic.user.permissions.profile',
'data' => (!empty($data['profile']) ? $data['profile'] : []),
'bundle' => 'user',
'level' => 'profile',
]
);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Mautic\UserBundle\Security\Provider;
use Mautic\CoreBundle\Cache\ResultCacheHelper;
use Mautic\CoreBundle\Cache\ResultCacheOptions;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\UserBundle\Entity\PermissionRepository;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Entity\UserRepository;
use Mautic\UserBundle\Event\UserEvent;
use Mautic\UserBundle\UserEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class UserProvider implements UserProviderInterface
{
public function __construct(
protected UserRepository $userRepository,
protected PermissionRepository $permissionRepository,
protected EventDispatcherInterface $dispatcher,
protected UserPasswordHasher $encoder,
) {
}
/**
* @param string $username
*/
public function loadUserByUsername($username): User
{
return $this->loadUserByIdentifier($username);
}
public function loadUserByIdentifier(string $identifier): User
{
$qb = $this->userRepository
->createQueryBuilder('u')
->select('u, r')
->leftJoin('u.role', 'r')
->where('u.username = :username OR u.email = :username')
->andWhere('u.isPublished = :true')
->setParameter('true', true, 'boolean')
->setParameter('username', $identifier);
$query = $qb->getQuery();
ResultCacheHelper::enableOrmQueryCache($query, new ResultCacheOptions(User::CACHE_NAMESPACE, 5 * 60));
$user = $query->getOneOrNullResult();
if (empty($user)) {
$message = sprintf(
'Unable to find an active admin MauticUserBundle:User object identified by "%s".',
$identifier
);
throw new UserNotFoundException($message, 0);
}
// load permissions
if ($user->getId()) {
$permissions = $this->permissionRepository->getPermissionsByRole($user->getRole());
$user->setActivePermissions($permissions);
}
return $user;
}
public function refreshUser(UserInterface $user): UserInterface
{
$class = $user::class;
if (!$this->supportsClass($class)) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $class));
}
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
public function supportsClass(string $class): bool
{
return User::class === $class || is_subclass_of($class, User::class);
}
/**
* Create/update user from authentication plugins.
*
* @param bool|true $createIfNotExists
*
* @return User
*
* @throws BadCredentialsException
*/
public function saveUser(User $user, $createIfNotExists = true)
{
$isNew = !$user->getId();
if ($isNew) {
$user = $this->findUser($user);
if (!$user->getId() && !$createIfNotExists) {
throw new BadCredentialsException();
}
}
// Validation for User objects returned by a plugin
if (!$user->getRole()) {
throw new AuthenticationException('mautic.integration.sso.error.no_role');
}
if (!$user->getUserIdentifier()) {
throw new AuthenticationException('mautic.integration.sso.error.no_username');
}
if (!$user->getEmail()) {
throw new AuthenticationException('mautic.integration.sso.error.no_email');
}
if (!$user->getFirstName() || !$user->getLastName()) {
throw new AuthenticationException('mautic.integration.sso.error.no_name');
}
// Check for plain password
$plainPassword = $user->getPlainPassword();
if ($plainPassword) {
// Encode plain text
$user->setPassword(
$this->encoder->hashPassword($user, $plainPassword)
);
} elseif (!$password = $user->getPassword()) {
// Generate and encode a random password
$user->setPassword(
$this->encoder->hashPassword($user, EncryptionHelper::generateKey())
);
}
$event = new UserEvent($user, $isNew);
if ($this->dispatcher->hasListeners(UserEvents::USER_PRE_SAVE)) {
$event = $this->dispatcher->dispatch($event, UserEvents::USER_PRE_SAVE);
}
$this->userRepository->saveEntity($user);
if ($this->dispatcher->hasListeners(UserEvents::USER_POST_SAVE)) {
$this->dispatcher->dispatch($event, UserEvents::USER_POST_SAVE);
}
return $user;
}
/**
* @return User
*/
public function findUser(User $user)
{
try {
// Try by username
$user = $this->loadUserByIdentifier($user->getUserIdentifier());
return $user;
} catch (UserNotFoundException) {
// Try by email
try {
return $this->loadUserByIdentifier($user->getEmail());
} catch (UserNotFoundException) {
}
}
return $user;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML;
use LightSaml\Builder\EntityDescriptor\SimpleEntityDescriptorBuilder;
use LightSaml\Credential\X509Credential;
use LightSaml\Store\Credential\CredentialStoreInterface;
use Symfony\Component\Routing\RouterInterface;
class EntityDescriptorProviderFactory
{
public static function build(
string $ownEntityId,
RouterInterface $router,
?string $routeName,
CredentialStoreInterface $ownCredentialStore,
): SimpleEntityDescriptorBuilder {
/** @var X509Credential[] $arrOwnCredentials */
$arrOwnCredentials = $ownCredentialStore->getByEntityId($ownEntityId);
$route = $routeName ? $router->generate($routeName, [], RouterInterface::ABSOLUTE_PATH) : '';
return new SimpleEntityDescriptorBuilder(
$ownEntityId,
$route ? sprintf('%s%s', $ownEntityId, $route) : '',
'',
$arrOwnCredentials[0]->getCertificate()
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\HttpFoundation\RequestStack;
class Helper
{
public function __construct(private CoreParametersHelper $coreParametersHelper, private RequestStack $request)
{
}
public function isSamlSession(): bool
{
return $this->isSamlEnabled() && $this->request->getSession()->has('samlsso');
}
public function isSamlEnabled(): bool
{
return (bool) $this->coreParametersHelper->get('saml_idp_metadata');
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Credential\KeyHelper;
use LightSaml\Credential\X509Certificate;
use LightSaml\Credential\X509Credential;
use LightSaml\Store\Credential\CredentialStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use RobRichards\XMLSecLibs\XMLSecurityKey;
class CredentialsStore implements CredentialStoreInterface
{
private ?X509Credential $credentials = null;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private string $entityId,
) {
}
public function getByEntityId($entityId): array
{
// EntityIds do not match
if ($entityId !== $this->entityId) {
return [];
}
if (!$this->credentials) {
$this->delegateAndCreateCredentials();
}
return [$this->credentials];
}
private function delegateAndCreateCredentials(): void
{
// Credentials are required or SP will cause a never ending login loop as it throws an exception
$samlEnabled = (bool) $this->coreParametersHelper->get('saml_idp_metadata');
if (!$samlEnabled || !$certificateContent = $this->coreParametersHelper->get('saml_idp_own_certificate')) {
$this->credentials = $this->createDefaultCredentials();
return;
}
$this->credentials = $this->createOwnCredentials();
}
private function createOwnCredentials(): X509Credential
{
$certificateContent = base64_decode($this->coreParametersHelper->get('saml_idp_own_certificate'));
$privateKeyContent = base64_decode($this->coreParametersHelper->get('saml_idp_own_private_key'));
$keyPassword = (string) $this->coreParametersHelper->get('saml_idp_own_password');
return $this->createCredentials($certificateContent, $privateKeyContent, $keyPassword);
}
private function createDefaultCredentials(): X509Credential
{
$cache_dir = $this->coreParametersHelper->get('cache_path');
$keyPassword = '';
if (!file_exists($cache_dir.'/saml_default.key') || !file_exists($cache_dir.'/saml_default.crt')) {
$dn = ['commonName' => 'Mautic dummy cert'];
// Generate a new private (and public) key pair
$privkey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
// Generate a certificate signing request
$csr = openssl_csr_new($dn, $privkey, ['digest_alg' => 'sha256']);
// Generate a self-signed cert, valid for 365 days
$x509 = openssl_csr_sign($csr, null, $privkey, $days=365, ['digest_alg' => 'sha256']);
openssl_x509_export_to_file($x509, $cache_dir.'/saml_default.crt');
openssl_pkey_export_to_file($privkey, $cache_dir.'/saml_default.key', $keyPassword);
}
$cert = file_get_contents($cache_dir.'/saml_default.crt');
$privateKey = file_get_contents($cache_dir.'/saml_default.key');
return $this->createCredentials($cert, $privateKey, $keyPassword);
}
private function createCertificate(string $certificateContent): X509Certificate
{
$certificate = new X509Certificate();
$certificate->loadPem($certificateContent);
return $certificate;
}
private function createPrivateKey(string $privateKeyContent, string $keyPassword, X509Certificate $certificate): XMLSecurityKey
{
return KeyHelper::createPrivateKey($privateKeyContent, $keyPassword, false, $certificate->getSignatureAlgorithm());
}
private function createCredentials(string $certificateContent, string $privateKeyContent, string $keyPassword): X509Credential
{
$certificate = $this->createCertificate($certificateContent);
$privateKey = $this->createPrivateKey($privateKeyContent, $keyPassword, $certificate);
$credentials = new X509Credential($certificate, $privateKey);
$credentials->setEntityId($this->entityId);
return $credentials;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Model\Metadata\EntityDescriptor;
use LightSaml\Store\EntityDescriptor\EntityDescriptorStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class EntityDescriptorStore implements EntityDescriptorStoreInterface
{
/**
* @var EntityDescriptor
*/
private $entityDescriptor;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
public function get($entityId): ?EntityDescriptor
{
if ($this->entityDescriptor) {
return $this->entityDescriptor;
}
$this->createEntityDescriptor();
if ($entityId !== $this->entityDescriptor->getEntityID()) {
return null;
}
return $this->entityDescriptor;
}
public function has($entityId): bool
{
// SAML is not enabled
if (!$this->coreParametersHelper->get('saml_idp_metadata')) {
return false;
}
$entityDescriptor = $this->get($entityId);
// EntityIds do not match
if (!$entityDescriptor) {
return false;
}
return true;
}
/**
* @return array|EntityDescriptor[]
*/
public function all(): array
{
if (!$this->entityDescriptor) {
$this->createEntityDescriptor();
}
return [$this->entityDescriptor];
}
private function createEntityDescriptor(): void
{
$xml = base64_decode($this->coreParametersHelper->get('saml_idp_metadata'));
$this->entityDescriptor = EntityDescriptor::loadXml($xml);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use Doctrine\Persistence\ObjectManager;
use LightSaml\Provider\TimeProvider\TimeProviderInterface;
use LightSaml\Store\Id\IdStoreInterface;
use Mautic\UserBundle\Entity\IdEntry;
class IdStore implements IdStoreInterface
{
public function __construct(
private ObjectManager $manager,
private TimeProviderInterface $timeProvider,
) {
}
/**
* @param string $entityId
* @param string $id
*/
public function set($entityId, $id, \DateTime $expiryTime): void
{
$idEntry = $this->manager->find(IdEntry::class, ['entityId' => $entityId, 'id' => $id]);
if (null == $idEntry) {
$idEntry = new IdEntry();
}
$idEntry->setEntityId($entityId)
->setId($id)
->setExpiryTime($expiryTime);
$this->manager->persist($idEntry);
$this->manager->flush();
}
/**
* @param string $entityId
* @param string $id
*/
public function has($entityId, $id): bool
{
/** @var IdEntry $idEntry */
$idEntry = $this->manager->find(IdEntry::class, ['entityId' => $entityId, 'id' => $id]);
if (null == $idEntry) {
return false;
}
if ($idEntry->getExpiryTime()->getTimestamp() < $this->timeProvider->getTimestamp()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security\SAML\Store\Request;
use LightSaml\State\Request\RequestState;
use LightSaml\Store\Request\AbstractRequestStateArrayStore;
use Mautic\CacheBundle\Cache\CacheProviderInterface;
class RequestStateStore extends AbstractRequestStateArrayStore
{
private string $prefix;
public function __construct(private CacheProviderInterface $cacheProvider, string $prefix, string $suffix)
{
$this->prefix = $prefix.$suffix;
}
public function set(RequestState $state): self
{
$id = $state->getId();
$item = $this->cacheProvider->getItem($this->prefix.$id);
$item->expiresAfter(2 * 60); // The login is valid for 2 minutes.
$item->set($state);
$this->cacheProvider->save($item);
return $this;
}
public function get($id): ?RequestState
{
$item = $this->cacheProvider->getItem($this->prefix.$id);
if (!$item->isHit()) {
return null;
}
$state = $item->get();
if (!$state instanceof RequestState) {
return null;
}
return $state;
}
public function remove($id): bool
{
return $this->cacheProvider->deleteItem($this->prefix.$id);
}
public function clear(): void
{
$this->cacheProvider->clear($this->prefix);
}
/**
* @return array<mixed>
*/
protected function getArray(): array
{
throw new \LogicException('Not implemented');
}
/**
* @param array<mixed> $arr
*/
protected function setArray(array $arr): AbstractRequestStateArrayStore
{
throw new \LogicException('Not implemented');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Mautic\UserBundle\Security\SAML\Store;
use LightSaml\Meta\TrustOptions\TrustOptions;
use LightSaml\Store\TrustOptions\TrustOptionsStoreInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class TrustOptionsStore implements TrustOptionsStoreInterface
{
private ?TrustOptions $trustOptions = null;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private string $entityId,
) {
}
public function get($entityId): TrustOptions
{
if ($this->trustOptions) {
return $this->trustOptions;
}
return $this->createTrustOptions();
}
public function has($entityId): bool
{
// SAML is not enabled
if (!$this->coreParametersHelper->get('saml_idp_metadata')) {
return false;
}
// EntityIds do not match
if ($entityId !== $this->entityId) {
return false;
}
return true;
}
private function createTrustOptions(): TrustOptions
{
$this->trustOptions = $trustOptions = new TrustOptions();
if (!$this->coreParametersHelper->get('saml_idp_own_certificate')) {
return $trustOptions;
}
$trustOptions->setSignAuthnRequest(true);
$trustOptions->setEncryptAssertions(true);
$trustOptions->setEncryptAuthnRequest(true);
$trustOptions->setSignAssertions(true);
$trustOptions->setSignResponse(true);
return $trustOptions;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\UserBundle\Security\SAML\User;
use Doctrine\ORM\EntityManagerInterface;
use LightSaml\Model\Protocol\Response;
use LightSaml\SpBundle\Security\User\UserCreatorInterface;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserInterface;
class UserCreator implements UserCreatorInterface
{
private int $defaultRole;
private array $requiredFields = [
'username',
'firstname',
'lastname',
'email',
];
public function __construct(
private EntityManagerInterface $entityManager,
private UserMapper $userMapper,
private UserModel $userModel,
private UserPasswordHasher $hasher,
$defaultRole,
) {
$this->defaultRole = (int) $defaultRole;
}
/**
* @return UserInterface|null
*/
public function createUser(Response $response): User
{
if (empty($this->defaultRole)) {
throw new BadCredentialsException('User does not exist.');
}
/** @var Role $defaultRole */
$defaultRole = $this->entityManager->getReference(Role::class, $this->defaultRole);
$user = $this->userMapper->getUser($response);
$user->setPassword($this->userModel->checkNewPassword($user, $this->hasher, EncryptionHelper::generateKey()));
$user->setRole($defaultRole);
$this->validateUser($user);
$this->userModel->saveEntity($user);
return $user;
}
/**
* @throws BadCredentialsException
*/
private function validateUser(User $user): void
{
// Validate that the user has all that's required
foreach ($this->requiredFields as $field) {
$getter = 'get'.ucfirst($field);
if (!$user->$getter()) {
throw new BadCredentialsException('User does not include required fields.');
}
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\UserBundle\Security\SAML\User;
use LightSaml\Model\Assertion\Assertion;
use LightSaml\Model\Protocol\Response;
use LightSaml\SpBundle\Security\User\UsernameMapperInterface;
use Mautic\UserBundle\Entity\User;
class UserMapper implements UsernameMapperInterface
{
/**
* @param array<string, mixed> $attributes
*/
public function __construct(
private array $attributes,
) {
}
public function getUser(Response $response): User
{
$user = new User();
foreach ($response->getAllAssertions() as $assertion) {
$this->setValuesFromAssertion($assertion, $user);
}
return $user;
}
public function getUsername(Response $response): ?string
{
$user = $this->getUser($response);
return $user->getUserIdentifier();
}
private function setValuesFromAssertion(Assertion $assertion, User $user): void
{
$attributes = $this->extractAttributes($assertion);
// use email as the user by default
if (isset($attributes['email'])) {
$user->setEmail($attributes['email']);
$user->setUsername($attributes['email']);
}
if (isset($attributes['username']) && !empty($attributes['username'])) {
$user->setUsername($attributes['username']);
}
if (isset($attributes['firstname'])) {
$user->setFirstname($attributes['firstname']);
}
if (isset($attributes['lastname'])) {
$user->setLastName($attributes['lastname']);
}
}
private function extractAttributes(Assertion $assertion): array
{
$attributes = [];
foreach ($this->attributes as $key => $attributeName) {
if (!$attributeName) {
continue;
}
foreach ($assertion->getAllAttributeStatements() as $attributeStatement) {
$attribute = $attributeStatement->getFirstAttributeByName($attributeName);
if ($attribute && $attribute->getFirstAttributeValue()) {
$attributes[$key] = $attribute->getFirstAttributeValue();
}
}
}
return $attributes;
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
class TimingSafeFormLoginAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface
{
/**
* @var array<mixed>
*/
private array $options;
/**
* @param array<mixed> $options
*/
public function __construct(private FormLoginAuthenticator $authenticator, private UserProviderInterface $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory, array $options)
{
$this->authenticator = $authenticator;
$this->userProvider = $userProvider;
$this->passwordHasherFactory = $passwordHasherFactory;
$this->options = array_merge([
'username_parameter' => '_username',
'password_parameter' => '_password',
'check_path' => '/login_check',
'post_only' => true,
'form_only' => false,
'enable_csrf' => false,
'csrf_parameter' => '_csrf_token',
'csrf_token_id' => 'authenticate',
], $options);
}
public function supports(Request $request): ?bool
{
return $this->authenticator->supports($request);
}
public function authenticate(Request $request): Passport
{
$credentials = $this->getCredentials($request);
$passwordHasherFactory = $this->passwordHasherFactory;
$userLoader = function (string $identifier) use ($passwordHasherFactory, $credentials): UserInterface {
try {
// Attempt to load the real user.
return $this->userProvider->loadUserByIdentifier($identifier);
} catch (UserNotFoundException $e) {
// If real user is not found, provide a dummy user and still 'check' the credentials to prevent
// user enumeration via response timing comparison.
// We check it against a pre-calculated hash so the verify functions take roughly
// the same amount of time, and we pass the actual entered password so the response
// timing varies with the given password the same way it does for existing users.
$user = new User();
$passwordHasherFactory->getPasswordHasher($user)->verify('$2y$13$aAwXNyqA87lcXQQuk8Cp6eo2amRywLct29oG2uWZ8lYBeamFZ8UhK', $credentials['password']);
// Rethrow exception
throw $e;
}
};
$userBadge = new UserBadge($credentials['username'], $userLoader);
$passport = new Passport($userBadge, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]);
if ($this->options['enable_csrf']) {
$passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token']));
}
if ($this->userProvider instanceof PasswordUpgraderInterface) {
$passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider));
}
return $passport;
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
return $this->authenticator->createToken($passport, $firewallName);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return $this->authenticator->onAuthenticationFailure($request, $exception);
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return $this->authenticator->start($request, $authException);
}
public function isInteractive(): bool
{
return $this->authenticator->isInteractive();
}
/**
* @return array<mixed>
*/
private function getCredentials(Request $request): array
{
$credentials = [];
$credentials['csrf_token'] = $request->get($this->options['csrf_parameter']);
if ($this->options['post_only']) {
$credentials['username'] = $request->request->get($this->options['username_parameter']);
$credentials['password'] = $request->request->get($this->options['password_parameter'], '');
} else {
$credentials['username'] = $request->get($this->options['username_parameter']);
$credentials['password'] = $request->get($this->options['password_parameter'], '');
}
if (!\is_string($credentials['username']) && !$credentials['username'] instanceof \Stringable) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username'])));
}
$credentials['username'] = trim($credentials['username']);
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']);
if (!\is_string($credentials['password']) && (!\is_object($credentials['password']) || !method_exists($credentials['password'], '__toString'))) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password'])));
}
if (!\is_string($credentials['csrf_token'] ?? '') && (!\is_object($credentials['csrf_token']) || !method_exists($credentials['csrf_token'], '__toString'))) {
throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['csrf_parameter'], \gettype($credentials['csrf_token'])));
}
return $credentials;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final class UserTokenSetter implements UserTokenSetterInterface
{
public function __construct(private UserModel $userModel, private TokenStorageInterface $tokenStorage)
{
}
public function setUser(int $userId): void
{
$user = $this->userModel->getEntity($userId);
$token = $this->tokenStorage->getToken() ?? new DummyToken();
$token->setUser($user);
$this->tokenStorage->setToken($token);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Mautic\UserBundle\Security;
interface UserTokenSetterInterface
{
public function setUser(int $userId): void;
}