added support for saml login (#1408)
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
44
src/EventSubscriber/ResetPasswordSubscriber.php
Normal file
44
src/EventSubscriber/ResetPasswordSubscriber.php
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
55
src/Migrations/Version20200125123942.php
Normal file
55
src/Migrations/Version20200125123942.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
86
src/Saml/Controller/SamlController.php
Normal file
86
src/Saml/Controller/SamlController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
56
src/Saml/Logout/SamlLogoutHandler.php
Normal file
56
src/Saml/Logout/SamlLogoutHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/Saml/Provider/SamlProvider.php
Normal file
90
src/Saml/Provider/SamlProvider.php
Normal 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
26
src/Saml/SamlAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Saml/SamlTokenFactory.php
Normal file
28
src/Saml/SamlTokenFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
src/Saml/Security/SamlAuthenticationSuccessHandler.php
Normal file
30
src/Saml/Security/SamlAuthenticationSuccessHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
src/Saml/Security/SamlFactory.php
Normal file
77
src/Saml/Security/SamlFactory.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/Saml/User/SamlUserFactory.php
Normal file
116
src/Saml/User/SamlUserFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/Security/DoctrineUserProvider.php
Normal file
77
src/Security/DoctrineUserProvider.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ class UserVoter extends AbstractVoter
|
||||
}
|
||||
|
||||
return $this->hasRolePermission($user, 'delete_user');
|
||||
} elseif ($attribute === 'password') {
|
||||
if (!$subject->isInternalUser()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$permission = $attribute;
|
||||
|
||||
Reference in New Issue
Block a user