Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -9,63 +9,66 @@
namespace App\Ldap;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use App\Configuration\LdapConfiguration;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class FormLoginLdapFactory implements SecurityFactoryInterface
final class FormLoginLdapFactory extends AbstractFactory implements AuthenticatorFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId)
public function __construct()
{
$authProviderId = $this->createAuthProvider($container, $id, $userProviderId);
$listenerId = $this->createListener($container, $id, $config);
return [$authProviderId, $listenerId, $defaultEntryPointId];
$this->addOption('username_parameter', '_username');
$this->addOption('password_parameter', '_password');
$this->addOption('csrf_parameter', '_csrf_token');
$this->addOption('csrf_token_id', 'authenticate');
$this->addOption('enable_csrf', false);
$this->addOption('post_only', true);
$this->addOption('form_only', false);
}
public function getPosition()
public function getPriority(): int
{
return 'pre_auth';
return -20;
}
public function getKey()
public function getKey(): string
{
return 'kimai_ldap';
}
public function addConfiguration(NodeDefinition $builder)
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
}
$key = $this->getKey();
protected function createAuthProvider(ContainerBuilder $container, $id, $userProviderId)
{
$providerId = 'security.authentication.provider.kimai_ldap.' . $id;
$authenticatorId = 'security.authenticator.form_login.' . $firewallName;
$options = array_intersect_key($config, $this->options);
$authenticator = $container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.form_login'))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(2, new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)))
->replaceArgument(3, new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)))
->replaceArgument(4, $options);
$container
->setDefinition($providerId, new ChildDefinition(LdapAuthenticationProvider::class))
->replaceArgument(1, $id)
->replaceArgument(2, new Reference($userProviderId))
if ($options['use_forward'] ?? false) {
$authenticator->addMethodCall('setHttpKernel', [new Reference('http_kernel')]);
}
$container->setDefinition('security.listener.' . $key . '.' . $firewallName, new Definition(LdapCredentialsSubscriber::class))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.' . $firewallName])
->addArgument(new Reference(LdapManager::class))
;
return $providerId;
}
$ldapAuthenticatorId = 'security.authenticator.' . $key . '.' . $firewallName;
$container->setDefinition($ldapAuthenticatorId, new Definition(LdapAuthenticator::class))
->setArguments([
new Reference($authenticatorId),
new Reference(LdapConfiguration::class),
]);
protected function createListener(ContainerBuilder $container, $id, $config)
{
$listener = 'security.authentication.listener.form';
$listenerId = $listener . '.' . $id;
$container
->setDefinition($listenerId, new ChildDefinition($listener))
->replaceArgument(4, $id)
->replaceArgument(5, $config)
;
return $listenerId;
return $ldapAuthenticatorId;
}
}

View File

@@ -1,122 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Provider\UserAuthenticationProvider;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapAuthenticationProvider extends UserAuthenticationProvider
{
private $userProvider;
private $ldapManager;
private $config;
public function __construct(UserCheckerInterface $userChecker, $providerKey, UserProviderInterface $userProvider, LdapManager $ldapManager, LdapConfiguration $config, $hideUserNotFoundExceptions = true)
{
parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions);
$this->ldapManager = $ldapManager;
$this->config = $config;
$this->userProvider = $userProvider;
}
public function supports(TokenInterface $token)
{
if (!$this->config->isActivated()) {
return false;
}
return parent::supports($token);
}
protected function retrieveUser($username, UsernamePasswordToken $token)
{
$user = $token->getUser();
if ($user instanceof UserInterface) {
return $user;
}
try {
// this will always query the internal database first...
// only first-time logins from LDAP user (not yet existing in local user database)
// will actually hit the LdapUserProvider
$user = $this->userProvider->loadUserByUsername($username);
// do not update the user here from LDAP, as we don't know if the user can be authenticated
} catch (UsernameNotFoundException $notFound) {
throw $notFound;
/* @phpstan-ignore-next-line */
} catch (\Exception $repositoryProblem) {
$e = new AuthenticationServiceException($repositoryProblem->getMessage(), (int) $repositoryProblem->getCode(), $repositoryProblem);
$e->setToken($token);
throw $e;
}
return $user;
}
/**
* The updateUser() call should theoretically happen in retrieveUser() but that would require an additional
* $this->ldapManager->bind($user, $token->getCredentials())
* to check if the user is still valid.
*
* Symfony calls retrieveUser() before checkAuthentication()
* and we should not used ldap->search() before ldap->bind()
*
* @param UserInterface $user
* @param UsernamePasswordToken $token
* @throws LdapDriverException
*/
protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token)
{
$currentUser = $token->getUser();
$presentedPassword = $token->getCredentials();
if ($currentUser instanceof UserInterface) {
if ('' === $presentedPassword) {
throw new BadCredentialsException(
'The password in the token is empty. Check `erase_credentials` in your `security.yaml`'
);
}
if (!$this->ldapManager->bind($currentUser, $presentedPassword)) {
throw new BadCredentialsException('The credentials were changed from another session.');
}
} else {
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (!$this->ldapManager->bind($user, $presentedPassword)) {
throw new BadCredentialsException('The presented password is invalid.');
}
}
if ($user instanceof User && null !== $user->getPreferenceValue('ldap.dn')) {
try {
$this->ldapManager->updateUser($user);
} catch (LdapDriverException $ex) {
throw new BadCredentialsException('Fetching user data/roles failed, probably DN is expired.');
}
}
}
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Ldap;
use App\Configuration\LdapConfiguration;
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\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException;
final class LdapAuthenticator implements AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface
{
public function __construct(private AuthenticatorInterface $authenticator, private LdapConfiguration $configuration)
{
}
public function supports(Request $request): bool
{
if (!class_exists('Laminas\Ldap\Ldap')) {
return false;
}
if (!$this->configuration->isActivated()) {
return false;
}
return $this->authenticator->supports($request);
}
public function authenticate(Request $request): Passport
{
$passport = $this->authenticator->authenticate($request);
$passport->addBadge(new LdapBadge());
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 isInteractive(): bool
{
if ($this->authenticator instanceof InteractiveAuthenticatorInterface) {
return $this->authenticator->isInteractive();
}
return false;
}
public function start(Request $request, AuthenticationException $authException = null): Response
{
if (!$this->authenticator instanceof AuthenticationEntryPointInterface) {
throw new NotAnEntryPointException(sprintf('Decorated authenticator "%s" does not implement interface "%s".', get_debug_type($this->authenticator), AuthenticationEntryPointInterface::class));
}
return $this->authenticator->start($request, $authException);
}
}

27
src/Ldap/LdapBadge.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Ldap;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class LdapBadge implements BadgeInterface
{
private bool $resolved = false;
public function markResolved(): void
{
$this->resolved = true;
}
public function isResolved(): bool
{
return $this->resolved;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Ldap;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
final class LdapCredentialsSubscriber implements EventSubscriberInterface
{
public function __construct(private LdapManager $ldapManager)
{
}
public static function getSubscribedEvents(): array
{
return [CheckPassportEvent::class => ['onCheckPassport']];
}
public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();
if (!$passport->hasBadge(LdapBadge::class)) {
return;
}
/** @var LdapBadge $ldapBadge */
$ldapBadge = $passport->getBadge(LdapBadge::class);
if ($ldapBadge->isResolved()) {
return;
}
if (!$passport instanceof Passport || !$passport->hasBadge(PasswordCredentials::class)) {
throw new \LogicException(sprintf('LDAP authentication requires a passport containing a user and password credentials, authenticator "%s" does not fulfill these requirements.', \get_class($event->getAuthenticator())));
}
/** @var PasswordCredentials $passwordCredentials */
$passwordCredentials = $passport->getBadge(PasswordCredentials::class);
if ($passwordCredentials->isResolved()) {
throw new \LogicException('LDAP authentication password verification cannot be completed because something else has already resolved the PasswordCredentials.');
}
$presentedPassword = $passwordCredentials->getPassword();
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
$user = $passport->getUser();
$ldapBadge->markResolved();
if (!($user instanceof User)) {
throw new BadCredentialsException('The presented user needs to be a Kimai user.');
}
if (!$user->isLdapUser()) {
return;
}
if (!$this->ldapManager->bind($user->getUserIdentifier(), $presentedPassword)) {
throw new BadCredentialsException('The presented password is invalid.');
}
try {
$this->ldapManager->updateUser($user);
} catch (LdapDriverException $ex) {
throw new BadCredentialsException('Fetching user data/roles failed, probably DN is expired.');
}
// make sure that the normal auth process is not triggered
$passwordCredentials->markResolved();
}
}

View File

@@ -13,48 +13,19 @@ use App\Configuration\LdapConfiguration;
use Laminas\Ldap\Exception\LdapException;
use Laminas\Ldap\Ldap;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
* @internal
*/
class LdapDriver
{
/**
* @var Ldap
*/
private $driver;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var LdapConfiguration
*/
private $config;
private ?Ldap $driver = null;
public function __construct(LdapConfiguration $config, LoggerInterface $logger = null)
public function __construct(private LdapConfiguration $config, private ?LoggerInterface $logger = null)
{
$this->config = $config;
$this->logger = $logger;
}
/**
* Do not initialize in the constructor, as it is called in some situations from the Symfony DI container,
* even if not actively used.
*
* So users without LDAP run into the exception which is thrown below if the package is not installed.
*
* To test the problematic behaviour:
* - switch to "dev" env
* - login as any user
* - change the user ID in the database
* - reload the page and see the exception
*
* @return Ldap
* @throws \Exception
*/
protected function getDriver()
protected function getDriver(): Ldap
{
if (null === $this->driver) {
if (!class_exists('Laminas\Ldap\Ldap')) {
@@ -63,7 +34,6 @@ class LdapDriver
'or deactivate LDAP, see https://www.kimai.org/documentation/ldap.html'
);
}
$this->driver = new Ldap($this->config->getConnectionParameters());
}
@@ -105,12 +75,10 @@ class LdapDriver
return $entries;
}
public function bind(UserInterface $user, string $password): bool
public function bind(string $bindDn, string $password): bool
{
$driver = $this->getDriver();
$bindDn = $user->getUsername();
try {
$this->logDebug('{action}({bindDn}, ****)', [
'action' => 'ldap_bind',
@@ -138,24 +106,18 @@ class LdapDriver
}
break;
// Other level codes
// Other level codes
default:
$this->logDebug('{exception}', ['exception' => $sanitizedException]);
break;
}
}
/**
* Log debug messages if the logger is set.
*
* @param string $message
* @param array $context
*/
private function logDebug(string $message, array $context = []): void
{
if (null === $this->logger) {
return;
}
$this->logger->debug($message, $context);
$this->logger->error($message, $context);
}
}

View File

@@ -9,10 +9,6 @@
namespace App\Ldap;
class LdapDriverException extends \Exception
final class LdapDriverException extends \Exception
{
public function __construct($message)
{
parent::__construct($message);
}
}

View File

@@ -11,24 +11,16 @@ namespace App\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use App\Security\RoleService;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*
* @final
*/
class LdapManager
{
private $driver;
private $hydrator;
private $config;
public function __construct(LdapDriver $driver, LdapUserHydrator $hydrator, LdapConfiguration $config)
public function __construct(private LdapDriver $driver, private LdapConfiguration $config, private RoleService $roles)
{
$this->config = $config;
$this->driver = $driver;
$this->hydrator = $hydrator;
}
/**
@@ -42,16 +34,8 @@ class LdapManager
{
$params = $this->config->getUserParameters();
return $this->findUserBy([$params['usernameAttribute'] => $username]);
}
$criteria = [$params['usernameAttribute'] => $username];
/**
* @param array $criteria
* @return User|null
* @throws LdapDriverException
*/
public function findUserBy(array $criteria): ?UserInterface
{
$params = $this->config->getUserParameters();
$filter = $this->buildFilter($criteria);
$entries = $this->driver->search($params['baseDn'], $filter);
@@ -65,7 +49,7 @@ class LdapManager
}
// do not updateUser() here, as this would happen before bind()
return $this->hydrator->hydrate($entries[0]);
return $this->hydrate($entries[0]);
}
private function buildFilter(array $criteria, string $condition = '&'): string
@@ -82,9 +66,9 @@ class LdapManager
return sprintf('(%s%s)', $condition, implode($filters));
}
public function bind(UserInterface $user, string $password): bool
public function bind(string $dn, string $password): bool
{
return $this->driver->bind($user, $password);
return $this->driver->bind($dn, $password);
}
/**
@@ -105,9 +89,9 @@ class LdapManager
}
// always look up the users current DN first, as the cached DN might have been renamed in LDAP
$userFresh = $this->findUserByUsername($user->getUsername());
$userFresh = $this->findUserByUsername($user->getUserIdentifier());
if (null === $userFresh || null === ($baseDn = $userFresh->getPreferenceValue('ldap.dn'))) {
throw new LdapDriverException(sprintf('Failed fetching user DN for %s', $user->getUsername()));
throw new LdapDriverException(sprintf('Failed fetching user DN for %s', $user->getUserIdentifier()));
}
$user->setPreferenceValue('ldap.dn', $baseDn);
@@ -122,7 +106,7 @@ class LdapManager
return;
}
$this->hydrator->hydrateUser($user, $entries[0]);
$this->hydrateUser($user, $entries[0]);
$roleParameter = $this->config->getRoleParameters();
if (null === $roleParameter['baseDn']) {
@@ -141,7 +125,7 @@ class LdapManager
$roles = $this->getRoles($roleValue, $roleParameter);
if (!empty($roles)) {
$this->hydrator->hydrateRoles($user, $roles);
$this->hydrateRoles($user, $roles);
}
}
@@ -155,4 +139,148 @@ class LdapManager
[$roleParameter['nameAttribute']]
);
}
// ===================================================================
private function createUser(): User
{
$user = new User();
$user->setEnabled(true);
return $user;
}
public function hydrate(array $ldapEntry): User
{
$user = $this->createUser();
$this->hydrateUser($user, $ldapEntry);
return $user;
}
public function hydrateUser(User $user, array $ldapEntry)
{
$userParams = $this->config->getUserParameters();
$attributeMap = [];
if (\array_key_exists('attributes', $userParams)) {
$attributeMap = $userParams['attributes'];
}
$attributeMap = array_merge(
[
['ldap_attr' => $userParams['usernameAttribute'], 'user_method' => 'setUserIdentifier'],
],
$attributeMap
);
$this->hydrateUserWithAttributesMap($user, $ldapEntry, $attributeMap);
/** @var string|array|null $email */
$email = $user->getEmail();
if (null === $email) {
$user->setEmail($user->getUserIdentifier());
}
// fill them after hydrating account, so they can't be overwritten
// by the mapping attributes
if ($user->getId() === null) {
$user->setPassword('');
}
$user->setAuth(User::AUTH_LDAP);
$user->setPreferenceValue('ldap.dn', $ldapEntry['dn']);
}
/**
* @param User $user
* @param array $entries
*/
public function hydrateRoles(User $user, array $entries)
{
$roleParams = $this->config->getRoleParameters();
$allowedRoles = $this->roles->getAvailableNames();
$groupNameMapping = [];
if (\array_key_exists('groups', $roleParams)) {
$groupNameMapping = $roleParams['groups'];
}
$roleNameAttr = $roleParams['nameAttribute'];
$roles = [];
for ($i = 0; $i < $entries['count']; $i++) {
$roleName = $entries[$i][$roleNameAttr][0];
$mapped = false;
foreach ($groupNameMapping as $attr) {
if ($roleName === $attr['ldap_value']) {
$roleName = $attr['role'];
$mapped = true;
}
}
if (!$mapped) {
$roleName = sprintf('ROLE_%s', self::slugify($roleName));
}
if (!\in_array($roleName, $allowedRoles, true)) {
continue;
}
$roles[] = $roleName;
}
$user->setRoles($roles);
}
private static function slugify(string $role): string
{
$role = preg_replace('/\W+/', '_', $role);
$role = trim($role, '_');
$role = strtoupper($role);
return $role;
}
private function hydrateUserWithAttributesMap(UserInterface $user, array $ldapUserAttributes, array $attributeMap)
{
$sawUsername = false;
/** @var array $attr */
foreach ($attributeMap as $attr) {
if (!\array_key_exists($attr['ldap_attr'], $ldapUserAttributes)) {
continue;
}
$ldapValue = $ldapUserAttributes[$attr['ldap_attr']];
if (\array_key_exists('count', $ldapValue)) {
unset($ldapValue['count']);
}
if (1 === \count($ldapValue)) {
$value = array_shift($ldapValue);
} else {
$value = $ldapValue;
}
// BC layer for 2.0
if ($attr['user_method'] === 'setUsername') {
@trigger_error('Your LDAP configuration is deprecated: change the attribute mapping from "setUsername" to "setUserIdentifier".', E_USER_DEPRECATED);
$attr['user_method'] = 'setUserIdentifier';
}
if ($attr['user_method'] === 'setEmail') {
if (\is_array($value)) {
$value = $value[0];
}
} elseif ($attr['user_method'] === 'setUserIdentifier') {
$sawUsername = true;
}
if (!method_exists($user, $attr['user_method'])) {
throw new \Exception('Unknown mapping method: ' . $attr['user_method']);
}
$user->{$attr['user_method']}($value);
}
if (!$sawUsername) {
throw new LdapDriverException('Missing username in LDAP hydration');
}
}
}

View File

@@ -1,154 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use App\Security\RoleService;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapUserHydrator
{
/**
* @var LdapConfiguration
*/
private $config;
/**
* @var RoleService
*/
private $roles;
public function __construct(LdapConfiguration $config, RoleService $roles)
{
$this->config = $config;
$this->roles = $roles;
}
protected function createUser(): User
{
$user = new User();
$user->setEnabled(true);
return $user;
}
public function hydrate(array $ldapEntry): User
{
$user = $this->createUser();
$this->hydrateUser($user, $ldapEntry);
return $user;
}
public function hydrateUser(User $user, array $ldapEntry)
{
$userParams = $this->config->getUserParameters();
$attributeMap = $userParams['attributes'];
$attributeMap = array_merge(
[
['ldap_attr' => $userParams['usernameAttribute'], 'user_method' => 'setUsername'],
],
$attributeMap
);
$this->hydrateUserWithAttributesMap($user, $ldapEntry, $attributeMap);
/** @var string|array|null $email */
$email = $user->getEmail();
if (\is_array($email)) {
$user->setEmail($email[0]);
}
if (null === $email) {
$user->setEmail($user->getUsername());
}
// fill them after hydrating account, so they can't be overwritten
// by the mapping attributes
if ($user->getId() === null) {
$user->setPassword('');
}
$user->setAuth(User::AUTH_LDAP);
$user->setPreferenceValue('ldap.dn', $ldapEntry['dn']);
}
/**
* @param User $user
* @param array $entries
*/
public function hydrateRoles(User $user, array $entries)
{
$roleParams = $this->config->getRoleParameters();
$allowedRoles = $this->roles->getAvailableNames();
$groupNameMapping = $roleParams['groups'];
$roleNameAttr = $roleParams['nameAttribute'];
$roles = [];
for ($i = 0; $i < $entries['count']; $i++) {
$roleName = $entries[$i][$roleNameAttr][0];
$mapped = false;
foreach ($groupNameMapping as $attr) {
if ($roleName === $attr['ldap_value']) {
$roleName = $attr['role'];
$mapped = true;
}
}
if (!$mapped) {
$roleName = sprintf('ROLE_%s', self::slugify($roleName));
}
if (!\in_array($roleName, $allowedRoles)) {
continue;
}
$roles[] = $roleName;
}
$user->setRoles($roles);
}
private static function slugify(string $role): string
{
$role = preg_replace('/\W+/', '_', $role);
$role = trim($role, '_');
$role = strtoupper($role);
return $role;
}
protected function hydrateUserWithAttributesMap(UserInterface $user, array $ldapUserAttributes, array $attributeMap)
{
/** @var array $attr */
foreach ($attributeMap as $attr) {
if (!\array_key_exists($attr['ldap_attr'], $ldapUserAttributes)) {
continue;
}
$ldapValue = $ldapUserAttributes[$attr['ldap_attr']];
if (\array_key_exists('count', $ldapValue)) {
unset($ldapValue['count']);
}
if (1 === \count($ldapValue)) {
$value = array_shift($ldapValue);
} else {
$value = $ldapValue;
}
$user->{$attr['user_method']}($value);
}
}
}

View File

@@ -12,61 +12,49 @@ namespace App\Ldap;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Overwritten to be able to deactivate LDAP via config switch.
*
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*
* @final
*/
class LdapUserProvider implements UserProviderInterface
final class LdapUserProvider implements UserProviderInterface
{
private $ldapManager;
private $logger;
public function __construct(LdapManager $ldapManager, LoggerInterface $logger = null)
public function __construct(private LdapManager $ldapManager, private ?LoggerInterface $logger = null)
{
$this->ldapManager = $ldapManager;
$this->logger = $logger;
}
public function loadUserByUsername($username)
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->ldapManager->findUserByUsername($username);
$user = $this->ldapManager->findUserByUsername($identifier);
if (empty($user)) {
$this->logInfo('User {username} {result} on LDAP', [
'action' => 'loadUserByUsername',
'username' => $username,
$this->logDebug('User {username} {result} on LDAP', [
'action' => 'loadUserByIdentifier',
'username' => $identifier,
'result' => 'not found',
]);
$ex = new UsernameNotFoundException(sprintf('User "%s" not found', $username));
$ex->setUsername($username);
$ex = new UserNotFoundException(sprintf('User "%s" not found', $identifier));
$ex->setUserIdentifier($identifier);
throw $ex;
}
$this->logInfo('User {username} {result} on LDAP', [
'action' => 'loadUserByUsername',
'username' => $username,
$this->logDebug('User {username} {result} on LDAP', [
'action' => 'loadUserByIdentifier',
'username' => $identifier,
'result' => 'found',
]);
return $user;
}
public function refreshUser(UserInterface $user)
public function refreshUser(UserInterface $user): UserInterface
{
if (!($user instanceof User) || !$this->supportsClass(\get_class($user))) {
if (!($user instanceof User)) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}
if (!$user->isLdapUser() && null === $user->getPreferenceValue('ldap.dn')) {
throw new UnsupportedUserException(sprintf('Account "%s" is not a registered LDAP user.', $user->getUsername()));
throw new UnsupportedUserException(sprintf('Account "%s" is not a registered LDAP user.', $user->getUserIdentifier()));
}
try {
@@ -77,26 +65,23 @@ class LdapUserProvider implements UserProviderInterface
$user->setAuth(User::AUTH_LDAP);
}
} catch (LdapDriverException $ex) {
throw new UnsupportedUserException(sprintf('Failed to refresh user "%s", probably DN is expired.', $user->getUsername()));
throw new UnsupportedUserException(sprintf('Failed to refresh user "%s", probably DN is expired.', $user->getUserIdentifier()));
}
return $user;
}
public function supportsClass($class)
public function supportsClass($class): bool
{
return $class === User::class;
}
/**
* Log a message into the logger if this exists.
*/
private function logInfo(string $message, array $context = []): void
private function logDebug(string $message, array $context = []): void
{
if (!$this->logger) {
if ($this->logger === null) {
return;
}
$this->logger->info($message, $context);
$this->logger->debug($message, $context);
}
}

View File

@@ -9,31 +9,22 @@
namespace App\Ldap;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class SanitizingException extends \Exception
final class SanitizingException extends \Exception
{
protected $actualException;
protected $secret;
public function __construct(\Exception $actualException, $secret)
public function __construct(private \Exception $actualException, private string $secret)
{
parent::__construct(
$this->stripSecret($actualException->getMessage(), $secret),
$actualException->getCode()
);
$this->actualException = $actualException;
$this->secret = $secret;
}
protected function stripSecret(string $message, string $secret)
protected function stripSecret(string $message, string $secret): string
{
return str_replace($secret, '****', $message);
}
public function __toString()
public function __toString(): string
{
return $this->stripSecret($this->actualException->__toString(), $this->secret);
}