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

@@ -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;
}
}