added support for saml login (#1408)

This commit is contained in:
Kevin Papst
2020-01-31 19:47:34 +01:00
committed by GitHub
parent 3ff46e06c0
commit 6a533579b7
47 changed files with 2278 additions and 77 deletions

View File

@@ -65,7 +65,8 @@ class AppExtension extends Extension
$this->createPermissionParameter($config['permissions'], $container);
$this->createThemeParameter($config['theme'], $container);
$this->createUserParameter($config['user'], $container);
$container->setParameter('kimai.saml', $config['saml']);
$container->setParameter('kimai.saml.connection', $config['saml']['connection']);
$container->setParameter('kimai.timesheet', $config['timesheet']);
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);

View File

@@ -29,6 +29,9 @@ class TwigContextCompilerPass implements CompilerPassInterface
$theme = $container->getDefinition(ThemeConfiguration::class);
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
$saml = $container->getParameter('kimai.saml');
$twig->addMethodCall('addGlobal', ['saml', $saml]);
if ($container->hasDefinition('twig.loader.native_filesystem')) {
$definition = $container->getDefinition('twig.loader.native_filesystem');

View File

@@ -66,6 +66,7 @@ class Configuration implements ConfigurationInterface
->append($this->getDefaultsNode())
->append($this->getPermissionsNode())
->append($this->getLdapNode())
->append($this->getSamlNode())
->end()
->end();
@@ -666,4 +667,194 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getSamlNode()
{
$builder = new TreeBuilder('saml');
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
->addDefaultsIfNotSet()
->children()
->booleanNode('activate')
->defaultFalse()
->end()
->scalarNode('title')
->defaultValue('Login with SAML')
->end()
->arrayNode('roles')
->addDefaultsIfNotSet()
->children()
->scalarNode('attribute')
->defaultNull()
->end()
->arrayNode('mapping')
->defaultValue([])
->arrayPrototype()
->children()
->scalarNode('saml')->isRequired()->cannotBeEmpty()->end()
->scalarNode('kimai')->isRequired()->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('mapping')
->defaultValue([])
->arrayPrototype()
->children()
->scalarNode('saml')->isRequired()->cannotBeEmpty()->end()
->scalarNode('kimai')->isRequired()->cannotBeEmpty()->end()
->end()
->end()
->end()
->arrayNode('connection')
->addDefaultsIfNotSet()
->children()
->scalarNode('baseurl')->end()
->booleanNode('strict')->end()
->booleanNode('debug')->end()
->arrayNode('idp')
->children()
->scalarNode('entityId')->end()
->scalarNode('x509cert')->end()
->arrayNode('singleSignOnService')
->children()
->scalarNode('url')->end()
->scalarNode('binding')->end()
->end()
->end()
->arrayNode('singleLogoutService')
->children()
->scalarNode('url')->end()
->scalarNode('binding')->end()
->end()
->end()
->scalarNode('certFingerprint')->end()
->scalarNode('certFingerprintAlgorithm')->end()
->arrayNode('x509certMulti')
->children()
->arrayNode('signing')
->prototype('scalar')->end()
->end()
->arrayNode('encryption')
->prototype('scalar')->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('sp')
->children()
->scalarNode('entityId')->end()
->scalarNode('NameIDFormat')->end()
->scalarNode('x509cert')->end()
->scalarNode('privateKey')->end()
->arrayNode('assertionConsumerService')
->children()
->scalarNode('url')->end()
->scalarNode('binding')->end()
->end()
->end()
->arrayNode('attributeConsumingService')
->children()
->scalarNode('serviceName')->end()
->scalarNode('serviceDescription')->end()
->arrayNode('requestedAttributes')
->prototype('array')
->children()
->scalarNode('name')->end()
->booleanNode('isRequired')->defaultValue(false)->end()
->scalarNode('nameFormat')->end()
->scalarNode('friendlyName')->end()
->arrayNode('attributeValue')->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('singleLogoutService')
->children()
->scalarNode('url')->end()
->scalarNode('binding')->end()
->end()
->end()
->end()
->end()
->arrayNode('security')
->children()
->booleanNode('nameIdEncrypted')->end()
->booleanNode('authnRequestsSigned')->end()
->booleanNode('logoutRequestSigned')->end()
->booleanNode('logoutResponseSigned')->end()
->booleanNode('wantMessagesSigned')->end()
->booleanNode('wantAssertionsSigned')->end()
->booleanNode('wantAssertionsEncrypted')->end()
->booleanNode('wantNameId')->end()
->booleanNode('wantNameIdEncrypted')->end()
->variableNode('requestedAuthnContext')
->validate()
->ifTrue(function ($v) {
return !is_bool($v) && !is_array($v);
})
->thenInvalid('Must be an array or a bool.')
->end()
->end()
->booleanNode('signMetadata')->end()
->booleanNode('wantXMLValidation')->end()
->booleanNode('lowercaseUrlencoding')->end()
->scalarNode('signatureAlgorithm')->end()
->scalarNode('digestAlgorithm')->end()
->scalarNode('entityManagerName')->end()
->end()
->end()
->arrayNode('contactPerson')
->children()
->arrayNode('technical')
->children()
->scalarNode('givenName')->end()
->scalarNode('emailAddress')->end()
->end()
->end()
->arrayNode('support')
->children()
->scalarNode('givenName')->end()
->scalarNode('emailAddress')->end()
->end()
->end()
->end()
->end()
->arrayNode('organization')
->prototype('array')
->children()
->scalarNode('name')->end()
->scalarNode('displayname')->end()
->scalarNode('url')->end()
->end()
->end()
->end()
->end()
->end()
->end()
->validate()
->ifTrue(static function ($v) {
if (true !== $v['activate']) {
return false;
}
$found = false;
foreach ($v['mapping'] as $mapping) {
if ($mapping['kimai'] === 'email') {
$found = true;
}
}
return !$found;
})
->thenInvalid('You need to configure a SAML mapping for the email attribute.')
->end()
;
return $node;
}
}

View File

@@ -38,6 +38,10 @@ class User extends BaseUser implements UserInterface
public const DEFAULT_ROLE = self::ROLE_USER;
public const DEFAULT_LANGUAGE = 'en';
public const AUTH_INTERNAL = 'kimai';
public const AUTH_LDAP = 'ldap';
public const AUTH_SAML = 'saml';
/**
* @var int
*
@@ -111,6 +115,13 @@ class User extends BaseUser implements UserInterface
*/
private $teams;
/**
* @var string
*
* @ORM\Column(name="auth", type="string", length=20, nullable=true)
*/
private $auth = self::AUTH_INTERNAL;
/**
* User constructor.
*/
@@ -308,6 +319,10 @@ class User extends BaseUser implements UserInterface
*/
public function addPreference(UserPreference $preference): User
{
if (null === $this->preferences) {
$this->preferences = new ArrayCollection();
}
$this->preferences->add($preference);
$preference->setUser($this);
@@ -372,6 +387,33 @@ class User extends BaseUser implements UserInterface
return $this->getUsername();
}
public function getAuth(): ?string
{
return $this->auth;
}
public function setAuth(string $auth): User
{
$this->auth = $auth;
return $this;
}
public function isSamlUser(): bool
{
return $this->auth === self::AUTH_SAML;
}
public function isLdapUser(): bool
{
return $this->auth === self::AUTH_LDAP;
}
public function isInternalUser(): bool
{
return $this->auth === null || $this->auth === self::AUTH_INTERNAL;
}
/**
* @return string
*/

View File

@@ -0,0 +1,44 @@
<?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\EventSubscriber;
use App\Entity\User;
use FOS\UserBundle\Event\GetResponseNullableUserEvent;
use FOS\UserBundle\FOSUserEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Makes sure that only internally registered users can reset their password.
*/
class ResetPasswordSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
FOSUserEvents::RESETTING_SEND_EMAIL_INITIALIZE => ['onInitializeResetPassword', 200]
];
}
public function onInitializeResetPassword(GetResponseNullableUserEvent $event)
{
$user = $event->getUser();
if (!($user instanceof User)) {
return;
}
// that is not nice :-D
if (!$user->isInternalUser()) {
throw new AccessDeniedHttpException(
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUsername(), $user->getAuth())
);
}
}
}

View File

@@ -23,6 +23,7 @@ use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface;
use App\Saml\Security\SamlFactory;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Timesheet\Rounding\RoundingInterface;
use App\Timesheet\TrackingMode\TrackingModeInterface;
@@ -85,6 +86,7 @@ class Kernel extends BaseKernel
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
$extension->addSecurityListenerFactory(new FormLoginLdapFactory());
$extension->addSecurityListenerFactory(new SamlFactory());
}
public function registerBundles()
@@ -174,6 +176,7 @@ class Kernel extends BaseKernel
}
$loader->load($confDir . '/packages/local' . self::CONFIG_EXTS, 'glob');
$loader->load($confDir . '/services' . self::CONFIG_EXTS, 'glob');
$loader->load($confDir . '/services-*' . self::CONFIG_EXTS, 'glob');
$loader->load($confDir . '/services_' . $this->environment . self::CONFIG_EXTS, 'glob');
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
@@ -189,6 +192,7 @@ class Kernel extends BaseKernel
// some routes are based on app configs and will be imported manually
$this->configureFosUserRoutes($routes);
$this->configureSamlRoutes($routes);
// load bundle specific route files
if (is_dir($confDir . '/routes/')) {
@@ -229,4 +233,15 @@ class Kernel extends BaseKernel
);
}
}
protected function configureSamlRoutes(RouteCollectionBuilder $routes)
{
$saml = $this->getContainer()->getParameter('kimai.saml');
if (!$saml['activate']) {
return;
}
$routes->import('../src/Saml/Controller/SamlController.php', '/auth', 'annotation');
}
}

View File

@@ -20,12 +20,12 @@ use Symfony\Component\DependencyInjection\Reference;
*/
class FormLoginLdapFactory implements SecurityFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId)
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPoint)
{
$authProviderId = $this->createAuthProvider($container, $id, $userProviderId);
$listenerId = $this->createListener($container, $id, $config);
return [$authProviderId, $listenerId, $defaultEntryPointId];
return [$authProviderId, $listenerId, $defaultEntryPoint];
}
public function getPosition()
@@ -44,11 +44,10 @@ class FormLoginLdapFactory implements SecurityFactoryInterface
protected function createAuthProvider(ContainerBuilder $container, $id, $userProviderId)
{
$provider = 'kimai_ldap.security.authentication.provider';
$providerId = $provider . '.' . $id;
$providerId = 'security.authentication.provider.kimai_ldap.' . $id;
$container
->setDefinition($providerId, new ChildDefinition($provider))
->setDefinition($providerId, new ChildDefinition(LdapAuthenticationProvider::class))
->replaceArgument(1, $id)
->replaceArgument(2, new Reference($userProviderId))
;
@@ -58,14 +57,14 @@ class FormLoginLdapFactory implements SecurityFactoryInterface
protected function createListener(ContainerBuilder $container, $id, $config)
{
$listenerId = 'security.authentication.listener.form';
$listener = 'security.authentication.listener.form';
$listenerId = $listener . '.' . $id;
$listener = new ChildDefinition($listenerId);
$listener->replaceArgument(4, $id);
$listener->replaceArgument(5, $config);
$listenerId .= '.' . $id;
$container->setDefinition($listenerId, $listener);
$container
->setDefinition($listenerId, new ChildDefinition($listener))
->replaceArgument(4, $id)
->replaceArgument(5, $config)
;
return $listenerId;
}

View File

@@ -71,8 +71,9 @@ class LdapUserHydrator
$user->setEmail($user->getUsername());
}
// prevent that users will define a password for the internal account
// fill them after hydrating account, so they can't be overwritten
$user->setPassword('');
$user->setAuth(User::AUTH_LDAP);
$user->setPreferenceValue('ldap.dn', $ldapEntry['dn']);
}

View File

@@ -73,12 +73,17 @@ class LdapUserProvider implements UserProviderInterface
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
}
if (null === $user->getPreferenceValue('ldap.dn')) {
if (!$user->isLdapUser() && null === $user->getPreferenceValue('ldap.dn')) {
throw new UnsupportedUserException(sprintf('Account "%s" is not a registered LDAP user.', $user->getUsername()));
}
try {
$this->ldapManager->updateUser($user);
// updating old LDAP accounts
if (!$user->isLdapUser() && null !== $user->getPreferenceValue('ldap.dn')) {
$user->setAuth(User::AUTH_LDAP);
}
} catch (LdapDriverException $ex) {
throw new UnsupportedUserException(sprintf('Failed to refresh user "%s", probably DN is expired.', $user->getUsername()));
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds a column to the user table to identify authenticator
*
* @version 1.8
*/
final class Version20200125123942 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds a column to the user table to identify authenticator';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->addColumn('auth', 'string', ['notnull' => false, 'length' => 20]);
}
public function down(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->dropColumn('auth');
}
}

View File

@@ -52,21 +52,16 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
*/
public function getUserById($id): ?User
{
try {
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getSingleResult();
} catch (\Exception $ex) {
}
return null;
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
/**
@@ -122,7 +117,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
/**
* @param string $username
* @return mixed|null|\Symfony\Component\Security\Core\User\UserInterface
* @return null|User
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
@@ -138,7 +133,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
->orWhere('u.email = :username')
->setParameter('username', $username)
->getQuery()
->getSingleResult();
->getOneOrNullResult();
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder

View File

@@ -0,0 +1,86 @@
<?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\Controller;
use App\Saml\SamlAuth;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* @Route(path="/saml")
*/
final class SamlController extends AbstractController
{
/**
* @var SamlAuth
*/
private $oneLoginAuth;
public function __construct(SamlAuth $oneLoginAuth)
{
$this->oneLoginAuth = $oneLoginAuth;
}
/**
* @Route(path="/login", name="saml_login")
*/
public function loginAction(Request $request)
{
$session = $request->getSession();
$authErrorKey = Security::AUTHENTICATION_ERROR;
if ($request->attributes->has($authErrorKey)) {
$error = $request->attributes->get($authErrorKey);
} elseif (null !== $session && $session->has($authErrorKey)) {
$error = $session->get($authErrorKey);
$session->remove($authErrorKey);
} else {
$error = null;
}
if ($error) {
throw new \RuntimeException($error->getMessage());
}
$this->oneLoginAuth->login($session->get('_security.main.target_path'));
}
/**
* @Route(path="/metadata", name="saml_metadata")
*/
public function metadataAction()
{
$metadata = $this->oneLoginAuth->getSettings()->getSPMetadata();
$response = new Response($metadata);
$response->headers->set('Content-Type', 'xml');
return $response;
}
/**
* @Route(path="/acs", name="saml_acs")
*/
public function assertionConsumerServiceAction()
{
throw new \RuntimeException('You must configure the check path in your firewall.');
}
/**
* @Route(path="/logout", name="saml_logout")
*/
public function logoutAction()
{
throw new \RuntimeException('You must configure the logout path in your firewall.');
}
}

View File

@@ -0,0 +1,56 @@
<?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\SamlAuth;
use Hslavich\OneloginSamlBundle\Security\Authentication\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 SamlAuth
*/
private $samlAuth;
public function __construct(SamlAuth $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;
}
try {
$this->samlAuth->processSLO();
} catch (Error $e) {
if (!empty($this->samlAuth->getSLOurl())) {
$sessionIndex = $token->hasAttribute('sessionIndex') ? $token->getAttribute('sessionIndex') : null;
$this->samlAuth->logout(null, [], $token->getUsername(), $sessionIndex);
}
}
}
}

View File

@@ -0,0 +1,90 @@
<?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\Repository\UserRepository;
use App\Saml\SamlTokenFactory;
use App\Saml\User\SamlUserFactory;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
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\ChainUserProvider;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class SamlProvider implements AuthenticationProviderInterface
{
/**
* @var UserProviderInterface
*/
private $userProvider;
/**
* @var SamlUserFactory
*/
private $userFactory;
/**
* @var SamlTokenFactory
*/
private $tokenFactory;
/**
* @var UserRepository
*/
private $repository;
public function __construct(UserRepository $repository, UserProviderInterface $userProvider, SamlTokenFactory $tokenFactory, SamlUserFactory $userFactory)
{
$this->repository = $repository;
$this->userProvider = $userProvider;
$this->tokenFactory = $tokenFactory;
$this->userFactory = $userFactory;
}
public function authenticate(TokenInterface $token)
{
$user = null;
/** @var ChainUserProvider $p */
$p = $this->userProvider;
try {
$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())
);
}
if ($user) {
$authenticatedToken = $this->tokenFactory->createToken($user, $token->getAttributes(), $user->getRoles());
$authenticatedToken->setAuthenticated(true);
return $authenticatedToken;
}
throw new AuthenticationException('The authentication failed.');
}
public function supports(TokenInterface $token)
{
return $token instanceof SamlTokenInterface;
}
}

26
src/Saml/SamlAuth.php Normal file
View File

@@ -0,0 +1,26 @@
<?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\Auth;
use OneLogin\Saml2\Utils;
use Symfony\Component\HttpFoundation\RequestStack;
class SamlAuth extends Auth
{
public function __construct(RequestStack $request, array $settings = null)
{
parent::__construct($settings);
if (null !== $request->getMasterRequest() && $request->getMasterRequest()->isFromTrustedProxy()) {
Utils::setProxyVars(true);
}
}
}

View File

@@ -0,0 +1,28 @@
<?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 Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenFactoryInterface;
final class SamlTokenFactory implements SamlTokenFactoryInterface
{
/**
* {@inheritdoc}
*/
public function createToken($user, array $attributes, array $roles)
{
$token = new SamlToken($roles);
$token->setUser($user);
$token->setAttributes($attributes);
return $token;
}
}

View File

@@ -0,0 +1,30 @@
<?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\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSuccessHandler
{
protected function determineTargetUrl(Request $request)
{
if ($this->options['always_use_default_target_path']) {
return $this->options['default_target_path'];
}
$relayState = $request->get('RelayState');
if (null !== $relayState && $relayState !== $this->httpUtils->generateUri($request, $this->options['login_path'])) {
return $relayState;
}
return parent::determineTargetUrl($request);
}
}

View File

@@ -0,0 +1,77 @@
<?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

@@ -0,0 +1,116 @@
<?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\User;
use App\Entity\User;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface;
final class SamlUserFactory implements SamlUserFactoryInterface
{
/**
* @var array
*/
private $mapping;
/**
* @var string
*/
private $groupAttribute;
/**
* @var array
*/
private $groupMapping;
public function __construct(array $attributes)
{
$this->mapping = $attributes['mapping'];
$this->groupAttribute = $attributes['roles']['attribute'];
$this->groupMapping = $attributes['roles']['mapping'];
}
public function createUser(SamlTokenInterface $token)
{
$user = new User();
$user->setEnabled(true);
$user->setUsername($token->getUsername());
$this->hydrateUser($user, $token);
return $user;
}
public function hydrateUser(User $user, SamlTokenInterface $token): void
{
// extract user roles from a special saml attribute
if (!empty($this->groupAttribute) && $token->hasAttribute($this->groupAttribute)) {
$groupMap = [];
foreach ($this->groupMapping as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$groupMap[$attribute] = $field;
}
$roles = [];
$samlGroups = $token->getAttribute($this->groupAttribute);
foreach ($samlGroups as $groupName) {
if (array_key_exists($groupName, $groupMap)) {
$roles[] = $groupMap[$groupName];
}
}
$user->setRoles($roles);
}
foreach ($this->mapping as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$value = $this->getPropertyValue($token, $attribute);
$setter = 'set' . ucfirst($field);
if (method_exists($user, $setter)) {
$user->$setter($value);
} else {
throw new \RuntimeException('Invalid mapping field given: ' . $field);
}
}
// fill them after hydrating account, so they can't be overwritten
$user->setUsername($token->getUsername());
$user->setPassword('');
$user->setAuth(User::AUTH_SAML);
}
private function getPropertyValue(SamlTokenInterface $token, $attribute)
{
$results = [];
$attributes = $token->getAttributes();
$parts = explode(' ', $attribute);
foreach ($parts as $part) {
if (empty(trim($part))) {
continue;
}
if ($part[0] === '$') {
$key = substr($part, 1);
if (!isset($attributes[$key])) {
throw new \RuntimeException('Missing user attribute: ' . $key);
}
$results[] = $attributes[$key][0];
} else {
$results[] = $part;
}
}
if (!empty($results)) {
return implode(' ', $results);
}
return $attribute;
}
}

View File

@@ -0,0 +1,77 @@
<?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\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface as SecurityUserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class DoctrineUserProvider implements UserProviderInterface
{
/**
* @var UserRepository
*/
private $repository;
public function __construct(UserRepository $repository)
{
$this->repository = $repository;
}
/**
* {@inheritdoc}
*/
public function loadUserByUsername($username)
{
$user = null;
try {
/** @var User $user */
$user = $this->repository->loadUserByUsername($username);
} catch (\Exception $ex) {
}
if (null === $user) {
throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
}
return $user;
}
/**
* {@inheritdoc}
*/
public function refreshUser(SecurityUserInterface $user)
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Expected an instance of %s, but got "%s".', User::class, get_class($user)));
}
/** @var User $reloadedUser */
$reloadedUser = $this->repository->getUserById($user->getId());
if (null === $reloadedUser) {
throw new UsernameNotFoundException(sprintf('User with ID "%s" could not be reloaded.', $user->getId()));
}
return $reloadedUser;
}
/**
* {@inheritdoc}
*/
public function supportsClass($class)
{
return $class === User::class || $class === 'App\Entity\User';
}
}

View File

@@ -67,6 +67,10 @@ class UserVoter extends AbstractVoter
}
return $this->hasRolePermission($user, 'delete_user');
} elseif ($attribute === 'password') {
if (!$subject->isInternalUser()) {
return false;
}
}
$permission = $attribute;