Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/Ldap/LdapAuthenticator.php
Normal file
82
src/Ldap/LdapAuthenticator.php
Normal 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
27
src/Ldap/LdapBadge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
82
src/Ldap/LdapCredentialsSubscriber.php
Normal file
82
src/Ldap/LdapCredentialsSubscriber.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
class LdapDriverException extends \Exception
|
||||
final class LdapDriverException extends \Exception
|
||||
{
|
||||
public function __construct($message)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user