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

@@ -1,75 +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\Saml\Firewall;
use App\Saml\SamlAuthFactory;
use App\Saml\Token\SamlToken;
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\Firewall\AbstractAuthenticationListener;
class SamlListener extends AbstractAuthenticationListener
{
/**
* @var SamlAuthFactory
*/
protected $authFactory;
public function setAuth(SamlAuthFactory $authFactory): void
{
$this->authFactory = $authFactory;
}
/**
* Performs authentication.
*
* @param Request $request A Request instance
* @return TokenInterface|Response|null The authenticated token, null if full authentication is not possible, or a Response
*
* @throws AuthenticationException if the authentication fails
* @throws \Exception if attribute set by "username_attribute" option not found
*/
protected function attemptAuthentication(Request $request)
{
$oneLoginAuth = $this->authFactory->create();
$oneLoginAuth->processResponse();
if ($oneLoginAuth->getErrors()) {
$this->logger->error($oneLoginAuth->getLastErrorReason());
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}
$attributes = [];
if (isset($this->options['use_attribute_friendly_name']) && $this->options['use_attribute_friendly_name']) {
$attributes = $oneLoginAuth->getAttributesWithFriendlyName();
} else {
$attributes = $oneLoginAuth->getAttributes();
}
$attributes['sessionIndex'] = $oneLoginAuth->getSessionIndex();
$token = new SamlToken();
$token->setAttributes($attributes);
if (isset($this->options['username_attribute'])) {
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
$this->logger->error(sprintf('Found attributes: %s', print_r($attributes, true)));
throw new \Exception(sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
}
$username = $attributes[$this->options['username_attribute']][0];
} else {
$username = $oneLoginAuth->getNameId();
}
$token->setUser($username);
return $this->authenticationManager->authenticate($token);
}
}

View File

@@ -1,58 +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\Saml\Logout;
use App\Saml\SamlAuthFactory;
use App\Saml\Token\SamlTokenInterface;
use OneLogin\Saml2\Error;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
final class SamlLogoutHandler implements LogoutHandlerInterface
{
/**
* @var SamlAuthFactory
*/
private $samlAuth;
public function __construct(SamlAuthFactory $samlAuth)
{
$this->samlAuth = $samlAuth;
}
/**
* This method is called by the LogoutListener when a user has requested
* to be logged out. Usually, you would unset session variables, or remove
* cookies, etc.
*
* @param Request $request
* @param Response $response
* @param TokenInterface $token
*/
public function logout(Request $request, Response $response, TokenInterface $token)
{
if (!$token instanceof SamlTokenInterface) {
return;
}
$samlAuth = $this->samlAuth->create();
try {
$samlAuth->processSLO();
} catch (Error $e) {
if (!empty($samlAuth->getSLOurl())) {
$sessionIndex = $token->hasAttribute('sessionIndex') ? $token->getAttribute('sessionIndex') : null;
$samlAuth->logout(null, [], $token->getUsername(), $sessionIndex);
}
}
}
}

View File

@@ -1,83 +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\Saml\Provider;
use App\Configuration\SamlConfigurationInterface;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Saml\SamlTokenFactory;
use App\Saml\Token\SamlTokenInterface;
use App\Saml\User\SamlUserFactory;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class SamlProvider implements AuthenticationProviderInterface
{
private $userProvider;
private $userFactory;
private $tokenFactory;
private $repository;
private $configuration;
public function __construct(UserRepository $repository, UserProviderInterface $userProvider, SamlTokenFactory $tokenFactory, SamlUserFactory $userFactory, SamlConfigurationInterface $configuration)
{
$this->repository = $repository;
$this->userProvider = $userProvider;
$this->tokenFactory = $tokenFactory;
$this->userFactory = $userFactory;
$this->configuration = $configuration;
}
/**
* @param SamlTokenInterface $token
* @return SamlTokenInterface
*/
public function authenticate(TokenInterface $token)
{
$user = null;
try {
/** @var User $user */
$user = $this->userProvider->loadUserByUsername($token->getUsername());
} catch (UsernameNotFoundException $e) {
}
try {
if (null === $user) {
$user = $this->userFactory->createUser($token);
} else {
$this->userFactory->hydrateUser($user, $token);
}
$this->repository->saveUser($user);
} catch (\Exception $ex) {
throw new AuthenticationException(
sprintf('Failed creating or hydrating user "%s": %s', $token->getUsername(), $ex->getMessage())
);
}
$authenticatedToken = $this->tokenFactory->createToken($user, $token->getAttributes(), $user->getRoles());
$authenticatedToken->setAuthenticated(true);
return $authenticatedToken;
}
public function supports(TokenInterface $token)
{
if (!$this->configuration->isActivated()) {
return false;
}
return $token instanceof SamlTokenInterface;
}
}

View File

@@ -19,18 +19,15 @@ use Symfony\Component\HttpFoundation\RequestStack;
*/
class SamlAuthFactory
{
private $request;
private $configuration;
public function __construct(RequestStack $request, SamlConfigurationInterface $configuration)
{
$this->request = $request;
$this->configuration = $configuration;
public function __construct(
private RequestStack $request,
private SamlConfigurationInterface $configuration
) {
}
public function create(): Auth
{
if (null !== $this->request->getMasterRequest() && $this->request->getMasterRequest()->isFromTrustedProxy()) {
if (null !== $this->request->getMainRequest() && $this->request->getMainRequest()->isFromTrustedProxy()) {
Utils::setProxyVars(true);
}

View File

@@ -0,0 +1,129 @@
<?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\Saml;
use App\Configuration\SamlConfiguration;
use App\Saml\Security\SamlAuthenticationFailureHandler;
use App\Saml\Security\SamlAuthenticationSuccessHandler;
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\AbstractAuthenticator;
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\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\HttpUtils;
/**
* @final
*/
class SamlAuthenticator extends AbstractAuthenticator
{
private array $options = [
'check_path' => 'saml_acs',
'login_path' => 'saml_login',
'use_attribute_friendly_name' => false,
];
public function __construct(
private HttpUtils $httpUtils,
private SamlAuthenticationSuccessHandler $successHandler,
private SamlAuthenticationFailureHandler $failureHandler,
private SamlAuthFactory $samlAuthFactory,
private SamlProvider $samlProvider,
private SamlConfiguration $configuration
) {
}
public function supports(Request $request): bool
{
if (!$this->configuration->isActivated()) {
return false;
}
if (!$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) {
return false;
}
return true;
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
$user = $passport->getUser();
$token = new SamlToken($user, $firewallName, $user->getRoles());
$token->setUser($user);
foreach ($passport->getBadges() as $badge) {
if ($badge instanceof SamlBadge) {
$token->setAttributes($badge->getSamlLoginAttributes()->getAttributes());
}
}
return $token;
}
public function authenticate(Request $request): Passport
{
$oneLoginAuth = $this->samlAuthFactory->create();
$oneLoginAuth->processResponse();
// $this->logger->debug('Received SAML response: ' . $oneLoginAuth->getLastResponseXML());
if ($oneLoginAuth->getErrors()) {
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}
$attributes = [];
if (isset($this->options['use_attribute_friendly_name']) && $this->options['use_attribute_friendly_name']) {
$attributes = $oneLoginAuth->getAttributesWithFriendlyName();
} else {
$attributes = $oneLoginAuth->getAttributes();
}
$attributes['sessionIndex'] = $oneLoginAuth->getSessionIndex();
$loginAttributes = new SamlLoginAttributes();
$loginAttributes->setAttributes($attributes);
if (isset($this->options['username_attribute'])) {
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
throw new \Exception(sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
}
$username = $attributes[$this->options['username_attribute']][0];
} else {
$username = $oneLoginAuth->getNameId();
}
$loginAttributes->setUserIdentifier($username);
$passport = new SelfValidatingPassport(
new UserBadge($loginAttributes->getUserIdentifier(), function () use ($loginAttributes) {
return $this->samlProvider->findUser($loginAttributes);
}),
[new RememberMeBadge(), new SamlBadge($loginAttributes)]
);
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);
}
}

29
src/Saml/SamlBadge.php Normal file
View File

@@ -0,0 +1,29 @@
<?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\Saml;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class SamlBadge implements BadgeInterface
{
public function __construct(private SamlLoginAttributes $samlToken)
{
}
public function getSamlLoginAttributes(): SamlLoginAttributes
{
return $this->samlToken;
}
public function isResolved(): bool
{
return true;
}
}

View File

@@ -0,0 +1,50 @@
<?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\Saml;
final class SamlLoginAttributes
{
private array $attributes = [];
private ?string $userIdentifier = null;
public function getAttributes(): array
{
return $this->attributes;
}
public function setAttributes(array $attributes): void
{
$this->attributes = $attributes;
}
public function hasAttribute(string $name): bool
{
return \array_key_exists($name, $this->attributes);
}
public function getAttribute(string $name): mixed
{
if (!\array_key_exists($name, $this->attributes)) {
throw new \InvalidArgumentException(sprintf('This SAML login has no "%s" attribute.', $name));
}
return $this->attributes[$name];
}
public function getUserIdentifier(): ?string
{
return $this->userIdentifier;
}
public function setUserIdentifier(?string $userIdentifier): void
{
$this->userIdentifier = $userIdentifier;
}
}

View File

@@ -0,0 +1,48 @@
<?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\Saml;
use OneLogin\Saml2\Error;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final class SamlLogoutSubscriber implements EventSubscriberInterface
{
public function __construct(private SamlAuthFactory $samlAuth)
{
}
public static function getSubscribedEvents(): array
{
return [
LogoutEvent::class => 'logout',
];
}
public function logout(LogoutEvent $event)
{
$token = $event->getToken();
if (!$token instanceof SamlToken) {
return;
}
$samlAuth = $this->samlAuth->create();
try {
$samlAuth->processSLO();
} catch (Error $e) {
if (!empty($samlAuth->getSLOurl())) {
$sessionIndex = $token->hasAttribute('sessionIndex') ? $token->getAttribute('sessionIndex') : null;
$samlAuth->logout(null, [], $token->getUserIdentifier(), $sessionIndex);
}
}
}
}

View File

@@ -7,27 +7,57 @@
* file that was distributed with this source code.
*/
namespace App\Saml\User;
namespace App\Saml;
use App\Configuration\SamlConfigurationInterface;
use App\Entity\User;
use App\Saml\Token\SamlTokenInterface;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class SamlUserFactory
final class SamlProvider
{
private $configuration;
public function __construct(SamlConfigurationInterface $configuration)
{
$this->configuration = $configuration;
public function __construct(
private UserRepository $repository,
private UserProviderInterface $userProvider,
private SamlConfigurationInterface $configuration
) {
}
public function createUser(SamlTokenInterface $token): User
public function findUser(SamlLoginAttributes $token): User
{
$user = null;
try {
/** @var User $user */
$user = $this->userProvider->loadUserByIdentifier($token->getUserIdentifier());
} catch (UserNotFoundException $e) {
}
try {
if (null === $user) {
$user = $this->createUser($token);
} else {
$this->hydrateUser($user, $token);
}
$this->repository->saveUser($user);
} catch (\Exception $ex) {
throw new AuthenticationException(
sprintf('Failed creating or hydrating user "%s": %s', $token->getUserIdentifier(), $ex->getMessage())
);
}
return $user;
}
private function createUser(SamlLoginAttributes $token): User
{
// Not using UserService: user settings should be set via SAML attributes
$user = new User();
$user->setEnabled(true);
$user->setUsername($token->getUsername());
$user->setUserIdentifier($token->getUserIdentifier());
$user->setPassword('');
$this->hydrateUser($user, $token);
@@ -35,7 +65,7 @@ final class SamlUserFactory
return $user;
}
public function hydrateUser(User $user, SamlTokenInterface $token): void
private function hydrateUser(User $user, SamlLoginAttributes $token): void
{
$groupAttribute = $this->configuration->getRolesAttribute();
$groupMapping = $this->configuration->getRolesMapping();
@@ -84,11 +114,11 @@ final class SamlUserFactory
if ($user->getId() === null) {
$user->setPassword('');
}
$user->setUsername($token->getUsername());
$user->setUserIdentifier($token->getUserIdentifier());
$user->setAuth(User::AUTH_SAML);
}
private function getPropertyValue(SamlTokenInterface $token, $attribute)
private function getPropertyValue(SamlLoginAttributes $token, $attribute)
{
$results = [];
$attributes = $token->getAttributes();

View File

@@ -7,10 +7,10 @@
* file that was distributed with this source code.
*/
namespace App\Saml\Token;
namespace App\Saml;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
interface SamlTokenInterface extends TokenInterface
final class SamlToken extends PostAuthenticationToken
{
}

View File

@@ -1,24 +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\Saml;
use App\Saml\Token\SamlToken;
final class SamlTokenFactory
{
public function createToken($user, array $attributes, array $roles): SamlToken
{
$token = new SamlToken($roles);
$token->setUser($user);
$token->setAttributes($attributes);
return $token;
}
}

View File

@@ -0,0 +1,22 @@
<?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\Saml\Security;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler;
final class SamlAuthenticationFailureHandler extends DefaultAuthenticationFailureHandler
{
protected $defaultOptions = [
'failure_path' => 'login',
'failure_forward' => false,
'login_path' => 'saml_login',
'failure_path_parameter' => '_failure_path',
];
}

View File

@@ -14,7 +14,15 @@ use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessH
final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSuccessHandler
{
protected function determineTargetUrl(Request $request)
protected $defaultOptions = [
'always_use_default_target_path' => false,
'default_target_path' => '/',
'login_path' => 'saml_login',
'target_path_parameter' => '_target_path',
'use_referer' => false,
];
protected function determineTargetUrl(Request $request): string
{
if ($this->options['always_use_default_target_path']) {
return $this->options['default_target_path'];

View File

@@ -1,77 +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\Saml\Security;
use App\Saml\Logout\SamlLogoutHandler;
use App\Saml\Provider\SamlProvider;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class SamlFactory extends AbstractFactory
{
public function __construct()
{
$this->addOption('check_path', 'saml_acs');
$this->addOption('failure_path', 'fos_user_security_login');
$this->addOption('success_handler', SamlAuthenticationSuccessHandler::class);
$this->defaultFailureHandlerOptions['login_path'] = 'saml_login';
}
protected function isRememberMeAware($config)
{
return false;
}
public function getPosition()
{
return 'pre_auth';
}
public function getKey()
{
return 'kimai_saml';
}
protected function getListenerId()
{
return 'kimai.saml_listener';
}
protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId)
{
$providerId = 'security.authentication.provider.saml.' . $id;
$definition = $container->setDefinition($providerId, new ChildDefinition(SamlProvider::class));
$definition->replaceArgument(1, new Reference($userProviderId));
return $providerId;
}
protected function createListener($container, $id, $config, $userProvider)
{
$listenerId = parent::createListener($container, $id, $config, $userProvider);
$this->createLogoutHandler($container, $id, $config);
return $listenerId;
}
private function createLogoutHandler(ContainerBuilder $container, $id, $config)
{
if ($container->hasDefinition('security.logout_listener.' . $id)) {
$logoutListener = $container->getDefinition('security.logout_listener.' . $id);
$container
->setDefinition(SamlLogoutHandler::class, new ChildDefinition('saml.security.http.logout'))
->replaceArgument(2, array_intersect_key($config, $this->options));
$logoutListener->addMethodCall('addHandler', [new Reference(SamlLogoutHandler::class)]);
}
}
}

View File

@@ -1,20 +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\Saml\Token;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
class SamlToken extends AbstractToken implements SamlTokenInterface
{
public function getCredentials()
{
return null;
}
}