added support for saml login (#1408)
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"jms/metadata": "^2.0",
|
||||
"jms/serializer-bundle": "^3.2",
|
||||
"kevinpapst/adminlte-bundle": "^3.0",
|
||||
"hslavich/oneloginsaml-bundle": "^1.4",
|
||||
"kimai/kimai2-composer": "^0.1",
|
||||
"laravolt/avatar": "^3.0",
|
||||
"league/csv": "^9.4",
|
||||
@@ -32,6 +33,7 @@
|
||||
"nelmio/api-doc-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^1.5",
|
||||
"ocramius/proxy-manager": "^2.1.1",
|
||||
"onelogin/php-saml": "^3.4",
|
||||
"phpoffice/phpspreadsheet": "^1.10",
|
||||
"phpoffice/phpword": "^0.17",
|
||||
"psr/log": "^1.1",
|
||||
|
||||
149
composer.lock
generated
149
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c4e30ced9c0035e4b1185044f9645fcb",
|
||||
"content-hash": "2fee31e8ce28cbf8048bae137c7e3f31",
|
||||
"packages": [
|
||||
{
|
||||
"name": "beberlei/doctrineextensions",
|
||||
@@ -2555,6 +2555,55 @@
|
||||
],
|
||||
"time": "2017-01-10T10:39:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "hslavich/oneloginsaml-bundle",
|
||||
"version": "v1.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hslavich/OneloginSamlBundle.git",
|
||||
"reference": "7a8e7e7f0bbf30bc0fcb662d52a4549b0eba3828"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/hslavich/OneloginSamlBundle/zipball/7a8e7e7f0bbf30bc0fcb662d52a4549b0eba3828",
|
||||
"reference": "7a8e7e7f0bbf30bc0fcb662d52a4549b0eba3828",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"onelogin/php-saml": "^3.0",
|
||||
"symfony/framework-bundle": "~2.3|~3.0|^4.0",
|
||||
"symfony/security-bundle": "~2.3|~3.0|^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/orm": "~2.3",
|
||||
"phpunit/phpunit": "~5.7",
|
||||
"satooshi/php-coveralls": "~1.0",
|
||||
"symfony/phpunit-bridge": "~2.7|~3.0|^4.0"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Hslavich\\OneloginSamlBundle\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "hslavich",
|
||||
"email": "hernan.slavich@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "OneLogin SAML Bundle for Symfony2",
|
||||
"keywords": [
|
||||
"SSO",
|
||||
"onelogin",
|
||||
"saml"
|
||||
],
|
||||
"time": "2019-02-05T14:15:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/cache",
|
||||
"version": "v6.13.1",
|
||||
@@ -3993,6 +4042,56 @@
|
||||
],
|
||||
"time": "2019-08-10T08:37:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "onelogin/php-saml",
|
||||
"version": "3.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/onelogin/php-saml.git",
|
||||
"reference": "5fbf3486704ac9835b68184023ab54862c95f213"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/onelogin/php-saml/zipball/5fbf3486704ac9835b68184023ab54862c95f213",
|
||||
"reference": "5fbf3486704ac9835b68184023ab54862c95f213",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.4",
|
||||
"robrichards/xmlseclibs": ">=3.0.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"pdepend/pdepend": "^2.5.0",
|
||||
"php-coveralls/php-coveralls": "^1.0.2 || ^2.0",
|
||||
"phploc/phploc": "^2.1 || ^3.0 || ^4.0",
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1",
|
||||
"sebastian/phpcpd": "^2.0 || ^3.0 || ^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.1.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs",
|
||||
"ext-gettext": "Install gettext and php5-gettext libs to handle translations",
|
||||
"ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OneLogin\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "OneLogin PHP SAML Toolkit",
|
||||
"homepage": "https://developers.onelogin.com/saml/php",
|
||||
"keywords": [
|
||||
"SAML2",
|
||||
"onelogin",
|
||||
"saml"
|
||||
],
|
||||
"time": "2019-11-25T17:30:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pagerfanta/pagerfanta",
|
||||
"version": "v2.1.3",
|
||||
@@ -4832,6 +4931,44 @@
|
||||
"description": "A polyfill for getallheaders.",
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "robrichards/xmlseclibs",
|
||||
"version": "3.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/robrichards/xmlseclibs.git",
|
||||
"reference": "0a53d3c3aa87564910cae4ed01416441d3ae0db5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/0a53d3c3aa87564910cae4ed01416441d3ae0db5",
|
||||
"reference": "0a53d3c3aa87564910cae4ed01416441d3ae0db5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"php": ">= 5.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"RobRichards\\XMLSecLibs\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"description": "A PHP library for XML Security",
|
||||
"homepage": "https://github.com/robrichards/xmlseclibs",
|
||||
"keywords": [
|
||||
"security",
|
||||
"signature",
|
||||
"xml",
|
||||
"xmldsig"
|
||||
],
|
||||
"time": "2019-11-05T11:44:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sensio/framework-extra-bundle",
|
||||
"version": "v5.5.3",
|
||||
@@ -8672,16 +8809,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/webpack-encore-bundle",
|
||||
"version": "v1.7.2",
|
||||
"version": "v1.7.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/webpack-encore-bundle.git",
|
||||
"reference": "787c2fdedde57788013339f05719c82ce07b6058"
|
||||
"reference": "5c0f659eceae87271cce54bbdfb05ed8ec9007bd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/787c2fdedde57788013339f05719c82ce07b6058",
|
||||
"reference": "787c2fdedde57788013339f05719c82ce07b6058",
|
||||
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/5c0f659eceae87271cce54bbdfb05ed8ec9007bd",
|
||||
"reference": "5c0f659eceae87271cce54bbdfb05ed8ec9007bd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8721,7 +8858,7 @@
|
||||
}
|
||||
],
|
||||
"description": "Integration with your Symfony app & Webpack Encore!",
|
||||
"time": "2019-11-26T14:48:41+00:00"
|
||||
"time": "2020-01-31T15:31:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
|
||||
@@ -5,11 +5,11 @@ security:
|
||||
providers:
|
||||
chain_provider:
|
||||
chain:
|
||||
providers: [fos_userbundle]
|
||||
providers: [kimai_internal]
|
||||
kimai_ldap:
|
||||
id: App\Ldap\LdapUserProvider
|
||||
fos_userbundle:
|
||||
id: fos_user.user_provider.username_email
|
||||
kimai_internal:
|
||||
id: App\Security\DoctrineUserProvider
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
@@ -54,6 +54,8 @@ security:
|
||||
ROLE_SUPER_ADMIN: ROLE_ADMIN
|
||||
|
||||
access_control:
|
||||
- { path: '^/auth/saml/login', roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/auth/saml/metadata', roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/(%app_locales%)$', role: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/(%app_locales%)/login', role: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
|
||||
32
config/services-saml.yaml
Normal file
32
config/services-saml.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
services:
|
||||
|
||||
# ================================================================================
|
||||
# SAML
|
||||
# ================================================================================
|
||||
|
||||
App\Saml\SamlAuth:
|
||||
alias: onelogin_auth
|
||||
|
||||
OneLogin\Saml2\Auth:
|
||||
alias: onelogin_auth
|
||||
|
||||
onelogin_auth:
|
||||
class: App\Saml\SamlAuth
|
||||
arguments: ['@request_stack', '%kimai.saml.connection%']
|
||||
|
||||
App\Saml\User\SamlUserFactory:
|
||||
arguments: ['%kimai.saml%']
|
||||
|
||||
kimai.saml_listener:
|
||||
class: Hslavich\OneloginSamlBundle\Security\Firewall\SamlListener
|
||||
parent: security.authentication.listener.abstract
|
||||
abstract: true
|
||||
calls:
|
||||
- [setOneLoginAuth, ["@onelogin_auth"]]
|
||||
|
||||
App\Saml\Provider\SamlProvider:
|
||||
arguments: ['@App\Repository\UserRepository', '', '@App\Saml\SamlTokenFactory', '@App\Saml\User\SamlUserFactory']
|
||||
|
||||
App\Saml\Security\SamlAuthenticationSuccessHandler:
|
||||
parent: security.authentication.success_handler
|
||||
@@ -146,8 +146,7 @@ services:
|
||||
# LDAP
|
||||
# ================================================================================
|
||||
|
||||
kimai_ldap.security.authentication.provider:
|
||||
class: App\Ldap\LdapAuthenticationProvider
|
||||
App\Ldap\LdapAuthenticationProvider:
|
||||
arguments: ['@App\Security\UserChecker', '', '', '', '@App\Configuration\LdapConfiguration', '%security.authentication.hide_user_not_found%']
|
||||
|
||||
# ================================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -186,6 +186,9 @@
|
||||
"hoa/zformat": {
|
||||
"version": "1.17.01.10"
|
||||
},
|
||||
"hslavich/oneloginsaml-bundle": {
|
||||
"version": "v1.4.1"
|
||||
},
|
||||
"illuminate/cache": {
|
||||
"version": "v6.0.4"
|
||||
},
|
||||
@@ -273,6 +276,9 @@
|
||||
"ocramius/proxy-manager": {
|
||||
"version": "2.1.1"
|
||||
},
|
||||
"onelogin/php-saml": {
|
||||
"version": "3.4.1"
|
||||
},
|
||||
"pagerfanta/pagerfanta": {
|
||||
"version": "v1.0.5"
|
||||
},
|
||||
@@ -369,6 +375,9 @@
|
||||
"ralouphie/getallheaders": {
|
||||
"version": "3.0.3"
|
||||
},
|
||||
"robrichards/xmlseclibs": {
|
||||
"version": "3.0.4"
|
||||
},
|
||||
"sebastian/code-unit-reverse-lookup": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -20,4 +20,13 @@
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
|
||||
{{ event.content|raw }}
|
||||
{% endblock %}
|
||||
|
||||
{% block login_social_auth %}
|
||||
{% if saml.activate %}
|
||||
<a href="{{ path('saml_login') }}" class="btn btn-block btn-google">
|
||||
{{ saml.title|trans }}
|
||||
</a>
|
||||
<br>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -57,6 +57,9 @@ class AppExtensionTest extends TestCase
|
||||
'data_dir' => '/tmp/',
|
||||
'plugin_dir' => '/tmp/',
|
||||
'timesheet' => [],
|
||||
'saml' => [
|
||||
'connection' => []
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -181,6 +181,46 @@ class ConfigurationTest extends TestCase
|
||||
$this->assertConfig($config, []);
|
||||
}
|
||||
|
||||
public function testValidateSamlIsMissingMappingForEmail()
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
$this->expectExceptionMessage('Invalid configuration for path "kimai.saml": You need to configure a SAML mapping for the email attribute.');
|
||||
|
||||
$config = $this->getMinConfig();
|
||||
$config['saml'] = [
|
||||
'activate' => true,
|
||||
'mapping' => [],
|
||||
];
|
||||
|
||||
$this->assertConfig($config, []);
|
||||
}
|
||||
|
||||
public function testValidateSamlDoesNotTriggerOnDeactivatedSaml()
|
||||
{
|
||||
$finalizedConfig = $this->getCompiledConfig($this->getMinConfig());
|
||||
$config = $this->getMinConfig();
|
||||
$config['saml'] = [
|
||||
'activate' => false,
|
||||
'mapping' => [],
|
||||
];
|
||||
|
||||
$this->assertConfig($config, $finalizedConfig);
|
||||
}
|
||||
|
||||
public function testValidateSamlDoesNotTriggerWhenEmailMappingExists()
|
||||
{
|
||||
$config = $this->getMinConfig();
|
||||
$config['saml'] = [
|
||||
'activate' => true,
|
||||
'mapping' => [
|
||||
['saml' => 'email', 'kimai' => 'email']
|
||||
],
|
||||
];
|
||||
$finalizedConfig = $this->getCompiledConfig($config);
|
||||
|
||||
$this->assertConfig($config, $finalizedConfig);
|
||||
}
|
||||
|
||||
public function testDefaultLdapSettings()
|
||||
{
|
||||
$finalizedConfig = $this->getCompiledConfig($this->getMinConfig());
|
||||
@@ -349,6 +389,18 @@ class ConfigurationTest extends TestCase
|
||||
'userDnAttribute' => 'member',
|
||||
'groups' => [],
|
||||
],
|
||||
],
|
||||
'saml' => [
|
||||
'activate' => false,
|
||||
'title' => 'Login with SAML',
|
||||
'roles' => [
|
||||
'attribute' => null,
|
||||
'mapping' => []
|
||||
],
|
||||
'mapping' => [],
|
||||
'connection' => [
|
||||
'organization' => []
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
@@ -24,23 +24,54 @@ class UserTest extends TestCase
|
||||
{
|
||||
$user = new User();
|
||||
$this->assertInstanceOf(ArrayCollection::class, $user->getPreferences());
|
||||
$this->assertNull($user->getTitle());
|
||||
$this->assertNull($user->getDisplayName());
|
||||
$this->assertNull($user->getAvatar());
|
||||
$this->assertNull($user->getAlias());
|
||||
$this->assertNull($user->getId());
|
||||
$this->assertNull($user->getApiToken());
|
||||
$this->assertNull($user->getPlainApiToken());
|
||||
$this->assertEquals(User::DEFAULT_LANGUAGE, $user->getLocale());
|
||||
self::assertNull($user->getTitle());
|
||||
self::assertNull($user->getDisplayName());
|
||||
self::assertNull($user->getAvatar());
|
||||
self::assertNull($user->getAlias());
|
||||
self::assertNull($user->getId());
|
||||
self::assertNull($user->getApiToken());
|
||||
self::assertNull($user->getPlainApiToken());
|
||||
self::assertEquals(User::DEFAULT_LANGUAGE, $user->getLocale());
|
||||
|
||||
$user->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
|
||||
$this->assertEquals('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', $user->getAvatar());
|
||||
self::assertEquals('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', $user->getAvatar());
|
||||
|
||||
$user->setApiToken('nbvfdswe34567ujko098765rerfghbgvfcdsert');
|
||||
$this->assertEquals('nbvfdswe34567ujko098765rerfghbgvfcdsert', $user->getApiToken());
|
||||
self::assertEquals('nbvfdswe34567ujko098765rerfghbgvfcdsert', $user->getApiToken());
|
||||
|
||||
$user->setPlainApiToken('https://www.gravatar.com/avatar/nbvfdswe34567ujko098765rerfghbgvfcdsert');
|
||||
$this->assertEquals('https://www.gravatar.com/avatar/nbvfdswe34567ujko098765rerfghbgvfcdsert', $user->getPlainApiToken());
|
||||
self::assertEquals('https://www.gravatar.com/avatar/nbvfdswe34567ujko098765rerfghbgvfcdsert', $user->getPlainApiToken());
|
||||
|
||||
$user->setTitle('Mr. Code Blaster');
|
||||
$this->assertEquals('Mr. Code Blaster', $user->getTitle());
|
||||
self::assertEquals('Mr. Code Blaster', $user->getTitle());
|
||||
}
|
||||
|
||||
public function testAuth()
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertEquals(User::AUTH_INTERNAL, $user->getAuth());
|
||||
self::assertFalse($user->isLdapUser());
|
||||
self::assertFalse($user->isSamlUser());
|
||||
self::assertTrue($user->isInternalUser());
|
||||
|
||||
$user->setAuth(User::AUTH_LDAP);
|
||||
self::assertEquals(User::AUTH_LDAP, $user->getAuth());
|
||||
self::assertTrue($user->isLdapUser());
|
||||
self::assertFalse($user->isSamlUser());
|
||||
self::assertFalse($user->isInternalUser());
|
||||
|
||||
$user->setAuth(User::AUTH_SAML);
|
||||
self::assertEquals(User::AUTH_SAML, $user->getAuth());
|
||||
self::assertFalse($user->isLdapUser());
|
||||
self::assertTrue($user->isSamlUser());
|
||||
self::assertFalse($user->isInternalUser());
|
||||
|
||||
$user->setAuth(User::AUTH_INTERNAL);
|
||||
self::assertEquals(User::AUTH_INTERNAL, $user->getAuth());
|
||||
self::assertFalse($user->isLdapUser());
|
||||
self::assertFalse($user->isSamlUser());
|
||||
self::assertTrue($user->isInternalUser());
|
||||
}
|
||||
|
||||
public function testDatetime()
|
||||
@@ -48,30 +79,30 @@ class UserTest extends TestCase
|
||||
$date = new \DateTime('+1 day');
|
||||
$user = new User();
|
||||
$user->setRegisteredAt($date);
|
||||
$this->assertEquals($date, $user->getRegisteredAt());
|
||||
self::assertEquals($date, $user->getRegisteredAt());
|
||||
}
|
||||
|
||||
public function testPreferences()
|
||||
{
|
||||
$user = new User();
|
||||
$this->assertNull($user->getPreference('test'));
|
||||
$this->assertNull($user->getPreferenceValue('test'));
|
||||
$this->assertEquals('foo', $user->getPreferenceValue('test', 'foo'));
|
||||
self::assertNull($user->getPreference('test'));
|
||||
self::assertNull($user->getPreferenceValue('test'));
|
||||
self::assertEquals('foo', $user->getPreferenceValue('test', 'foo'));
|
||||
|
||||
$preference = new UserPreference();
|
||||
$preference
|
||||
->setName('test')
|
||||
->setValue('foobar');
|
||||
$user->addPreference($preference);
|
||||
$this->assertEquals('foobar', $user->getPreferenceValue('test', 'foo'));
|
||||
$this->assertEquals($preference, $user->getPreference('test'));
|
||||
self::assertEquals('foobar', $user->getPreferenceValue('test', 'foo'));
|
||||
self::assertEquals($preference, $user->getPreference('test'));
|
||||
|
||||
$user->setPreferenceValue('test', 'Hello World');
|
||||
$this->assertEquals('Hello World', $user->getPreferenceValue('test', 'foo'));
|
||||
self::assertEquals('Hello World', $user->getPreferenceValue('test', 'foo'));
|
||||
|
||||
$this->assertNull($user->getPreferenceValue('test2'));
|
||||
self::assertNull($user->getPreferenceValue('test2'));
|
||||
$user->setPreferenceValue('test2', 'I like rain');
|
||||
$this->assertEquals('I like rain', $user->getPreferenceValue('test2'));
|
||||
self::assertEquals('I like rain', $user->getPreferenceValue('test2'));
|
||||
}
|
||||
|
||||
public function testDisplayName()
|
||||
@@ -79,28 +110,28 @@ class UserTest extends TestCase
|
||||
$user = new User();
|
||||
|
||||
$user->setUsername('bar');
|
||||
$this->assertEquals('bar', $user->getDisplayName());
|
||||
$this->assertEquals('bar', $user->getUsername());
|
||||
$this->assertEquals('bar', (string) $user);
|
||||
self::assertEquals('bar', $user->getDisplayName());
|
||||
self::assertEquals('bar', $user->getUsername());
|
||||
self::assertEquals('bar', (string) $user);
|
||||
|
||||
$user->setAlias('foo');
|
||||
$this->assertEquals('foo', $user->getAlias());
|
||||
$this->assertEquals('bar', $user->getUsername());
|
||||
$this->assertEquals('foo', $user->getDisplayName());
|
||||
$this->assertEquals('foo', (string) $user);
|
||||
self::assertEquals('foo', $user->getAlias());
|
||||
self::assertEquals('bar', $user->getUsername());
|
||||
self::assertEquals('foo', $user->getDisplayName());
|
||||
self::assertEquals('foo', (string) $user);
|
||||
}
|
||||
|
||||
public function testGetLocale()
|
||||
{
|
||||
$sut = new User();
|
||||
$this->assertEquals(User::DEFAULT_LANGUAGE, $sut->getLocale());
|
||||
self::assertEquals(User::DEFAULT_LANGUAGE, $sut->getLocale());
|
||||
|
||||
$language = new UserPreference();
|
||||
$language->setName(UserPreference::LOCALE);
|
||||
$language->setValue('fr');
|
||||
$sut->addPreference($language);
|
||||
|
||||
$this->assertEquals('fr', $sut->getLocale());
|
||||
self::assertEquals('fr', $sut->getLocale());
|
||||
}
|
||||
|
||||
public function testTeams()
|
||||
@@ -142,4 +173,25 @@ class UserTest extends TestCase
|
||||
$sut->addRole(User::ROLE_TEAMLEAD);
|
||||
self::assertTrue($sut->isTeamlead());
|
||||
}
|
||||
|
||||
public function testPreferencesCollectionIsCreatedOnBrokenUser()
|
||||
{
|
||||
// this code is only used in some rare edge cases, maybe even only in development ...
|
||||
// lets keep it, as it occured during the work on SAML authentication
|
||||
$sut = new User();
|
||||
|
||||
$preference = new UserPreference();
|
||||
$preference
|
||||
->setName('test')
|
||||
->setValue('foobar');
|
||||
|
||||
$property = new \ReflectionProperty(User::class, 'preferences');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($sut, null);
|
||||
|
||||
// make sure that addPreference will work, even if the internal collection was set to null
|
||||
$sut->addPreference($preference);
|
||||
|
||||
self::assertEquals('foobar', $sut->getPreferenceValue('test'));
|
||||
}
|
||||
}
|
||||
|
||||
89
tests/EventSubscriber/ResetPasswordSubscriberTest.php
Normal file
89
tests/EventSubscriber/ResetPasswordSubscriberTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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\Tests\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\EventSubscriber\ResetPasswordSubscriber;
|
||||
use App\Tests\Security\TestUserEntity;
|
||||
use FOS\UserBundle\Event\GetResponseNullableUserEvent;
|
||||
use FOS\UserBundle\FOSUserEvents;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* @covers \App\EventSubscriber\ResetPasswordSubscriber
|
||||
*/
|
||||
class ResetPasswordSubscriberTest extends TestCase
|
||||
{
|
||||
public function testGetSubscribedEvents()
|
||||
{
|
||||
$events = ResetPasswordSubscriber::getSubscribedEvents();
|
||||
self::assertCount(1, $events);
|
||||
$this->assertArrayHasKey(FOSUserEvents::RESETTING_SEND_EMAIL_INITIALIZE, $events);
|
||||
$methodName = $events[FOSUserEvents::RESETTING_SEND_EMAIL_INITIALIZE][0];
|
||||
$this->assertTrue(method_exists(ResetPasswordSubscriber::class, $methodName));
|
||||
}
|
||||
|
||||
public function testUnknownUserTypeIsIgnored()
|
||||
{
|
||||
$user = new TestUserEntity();
|
||||
$user->setUsername('foo@bar');
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$event = new GetResponseNullableUserEvent($user, $request);
|
||||
|
||||
$sut = new ResetPasswordSubscriber();
|
||||
$sut->onInitializeResetPassword($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testInternalAuthTypeIsIgnored()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('foo@bar');
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$event = new GetResponseNullableUserEvent($user, $request);
|
||||
|
||||
$sut = new ResetPasswordSubscriber();
|
||||
$sut->onInitializeResetPassword($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getAuthTypeData
|
||||
*/
|
||||
public function testNonInternalAuthTypeThrowsAccessDeniedException(string $authType)
|
||||
{
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage(sprintf('The user "foo@bar" tried to reset the password, but it is registered as "%s" auth-type.', $authType));
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('foo@bar');
|
||||
$user->setAuth($authType);
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$event = new GetResponseNullableUserEvent($user, $request);
|
||||
|
||||
$sut = new ResetPasswordSubscriber();
|
||||
$sut->onInitializeResetPassword($event);
|
||||
}
|
||||
|
||||
public function getAuthTypeData()
|
||||
{
|
||||
return [
|
||||
[User::AUTH_SAML],
|
||||
[User::AUTH_LDAP],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,12 @@ class FormLoginLdapFactoryTest extends TestCase
|
||||
$result = $sut->create($container, 'test', ['foo' => 'bar'], 'fosuserbundle', 'secured_area');
|
||||
|
||||
self::assertEquals([
|
||||
'kimai_ldap.security.authentication.provider.test',
|
||||
'security.authentication.provider.kimai_ldap.test',
|
||||
'security.authentication.listener.form.test',
|
||||
'secured_area'
|
||||
], $result);
|
||||
|
||||
$definition = $container->getDefinition('kimai_ldap.security.authentication.provider.test');
|
||||
$definition = $container->getDefinition('security.authentication.provider.kimai_ldap.test');
|
||||
self::assertInstanceOf(ChildDefinition::class, $definition);
|
||||
self::assertEquals('test', $definition->getArguments()['index_1']);
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ class LdapManagerTest extends TestCase
|
||||
|
||||
$userOrig = clone $user;
|
||||
$sut->updateUser($user);
|
||||
self::assertEquals($userOrig->setEmail('foobar'), $user);
|
||||
self::assertEquals($userOrig->setEmail('foobar')->setAuth(User::AUTH_LDAP), $user);
|
||||
self::assertEquals($user->getPreferenceValue('ldap.dn'), 'blub-updated');
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ class LdapManagerTest extends TestCase
|
||||
$user = (new User())->setUsername('Karl-Heinz');
|
||||
$user->setPreferenceValue('ldap.dn', 'blub');
|
||||
$userOrig = clone $user;
|
||||
$userOrig->setEmail('Karl-Heinz')->setRoles(['ROLE_TEAMLEAD', 'ROLE_ADMIN']);
|
||||
$userOrig->setEmail('Karl-Heinz')->setRoles(['ROLE_TEAMLEAD', 'ROLE_ADMIN'])->setAuth(User::AUTH_LDAP);
|
||||
|
||||
$sut->updateUser($user);
|
||||
self::assertEquals($userOrig, $user);
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
namespace App\Tests\Ldap;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Ldap\LdapDriverException;
|
||||
use App\Ldap\LdapManager;
|
||||
use App\Ldap\LdapUserProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,7 @@ class LdapUserProviderTest extends TestCase
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
$user->setPreferenceValue('ldap.dn', 'sdfdsf');
|
||||
self::assertFalse($user->isLdapUser());
|
||||
|
||||
$manager = $this->getMockBuilder(LdapManager::class)->disableOriginalConstructor()->onlyMethods(['updateUser'])->getMock();
|
||||
|
||||
@@ -59,5 +62,36 @@ class LdapUserProviderTest extends TestCase
|
||||
|
||||
self::assertInstanceOf(User::class, $actual);
|
||||
self::assertSame($user, $actual);
|
||||
self::assertTrue($user->isLdapUser());
|
||||
}
|
||||
|
||||
public function testRefreshUserThrowsExceptionOnNonLdapUser()
|
||||
{
|
||||
$this->expectException(UnsupportedUserException::class);
|
||||
$this->expectExceptionMessage('Account "foobar" is not a registered LDAP user.');
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$manager = $this->getMockBuilder(LdapManager::class)->disableOriginalConstructor()->onlyMethods(['updateUser'])->getMock();
|
||||
|
||||
$sut = new LdapUserProvider($manager);
|
||||
$actual = $sut->refreshUser($user);
|
||||
}
|
||||
|
||||
public function testRefreshUserThrowsExceptionOnBrokenUpdateUser()
|
||||
{
|
||||
$this->expectException(UnsupportedUserException::class);
|
||||
$this->expectExceptionMessage('Failed to refresh user "foobar", probably DN is expired.');
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
$user->setPreferenceValue('ldap.dn', 'sdfdsf');
|
||||
|
||||
$manager = $this->getMockBuilder(LdapManager::class)->disableOriginalConstructor()->onlyMethods(['updateUser'])->getMock();
|
||||
$manager->expects($this->once())->method('updateUser')->willThrowException(new LdapDriverException('blub'));
|
||||
|
||||
$sut = new LdapUserProvider($manager);
|
||||
$actual = $sut->refreshUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
84
tests/Mocks/Saml/SamlAuthFactory.php
Normal file
84
tests/Mocks/Saml/SamlAuthFactory.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?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\Tests\Mocks\Saml;
|
||||
|
||||
use App\Saml\SamlAuth;
|
||||
use App\Tests\Mocks\AbstractMockFactory;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
class SamlAuthFactory extends AbstractMockFactory
|
||||
{
|
||||
public function create(?array $connection = null): SamlAuth
|
||||
{
|
||||
if (null === $connection) {
|
||||
$connection = [
|
||||
'idp' => [
|
||||
'entityId' => 'https://accounts.google.com/o/saml2?idpid=',
|
||||
'singleSignOnService' => [
|
||||
'url' => 'https://accounts.google.com/o/saml2/idp?idpid=',
|
||||
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
],
|
||||
'x509cert' => 'asdf',
|
||||
],
|
||||
'sp' => [
|
||||
'entityId' => 'https://127.0.0.1:8010/auth/saml/metadata',
|
||||
'assertionConsumerService' => [
|
||||
'url' => 'https://127.0.0.1:8010/auth/saml/acs',
|
||||
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
],
|
||||
'singleLogoutService' => [
|
||||
'url' => 'https://127.0.0.1:8010/auth/saml/logout',
|
||||
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
],
|
||||
'privateKey' => ''
|
||||
],
|
||||
'strict' => true,
|
||||
'debug' => true,
|
||||
'security' => [
|
||||
'nameIdEncrypted' => false,
|
||||
'authnRequestsSigned' => false,
|
||||
'logoutRequestSigned' => false,
|
||||
'logoutResponseSigned' => false,
|
||||
'wantMessagesSigned' => false,
|
||||
'wantAssertionsSigned' => false,
|
||||
'wantNameIdEncrypted' => false,
|
||||
'requestedAuthnContext' => true,
|
||||
'signMetadata' => false,
|
||||
'wantXMLValidation' => true,
|
||||
'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
|
||||
'digestAlgorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256',
|
||||
],
|
||||
'contactPerson' => [
|
||||
'technical' => [
|
||||
'givenName' => 'Kimai Admin',
|
||||
'emailAddress' => 'kimai-tech@example.com',
|
||||
],
|
||||
'support' => [
|
||||
'givenName' => 'Kimai Support',
|
||||
'emailAddress' => 'kimai-support@example.com',
|
||||
]
|
||||
],
|
||||
'organization' => [
|
||||
'en' => [
|
||||
'name' => 'Kimai',
|
||||
'displayname' => 'Kimai',
|
||||
'url' => 'https://www.kimai.org',
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$requestStack = new RequestStack();
|
||||
$requestStack->push(new Request());
|
||||
|
||||
return new SamlAuth($requestStack, $connection);
|
||||
}
|
||||
}
|
||||
45
tests/Saml/Controller/SamlControllerTest.php
Normal file
45
tests/Saml/Controller/SamlControllerTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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\Tests\Saml\Controller;
|
||||
|
||||
use App\Saml\Controller\SamlController;
|
||||
use App\Tests\Mocks\Saml\SamlAuthFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
*/
|
||||
class SamlControllerTest extends TestCase
|
||||
{
|
||||
protected function getAuth()
|
||||
{
|
||||
return (new SamlAuthFactory($this))->create();
|
||||
}
|
||||
|
||||
public function testAssertionConsumerServiceAction()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('You must configure the check path in your firewall.');
|
||||
|
||||
$oauth = $this->getAuth();
|
||||
$sut = new SamlController($oauth);
|
||||
$sut->assertionConsumerServiceAction();
|
||||
}
|
||||
|
||||
public function testLogoutAction()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('You must configure the logout path in your firewall.');
|
||||
|
||||
$oauth = $this->getAuth();
|
||||
$sut = new SamlController($oauth);
|
||||
$sut->logoutAction();
|
||||
}
|
||||
}
|
||||
62
tests/Saml/Logout/SamlLogoutHandlerTest.php
Normal file
62
tests/Saml/Logout/SamlLogoutHandlerTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?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\Tests\Saml\Logout;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Saml\Logout\SamlLogoutHandler;
|
||||
use App\Saml\SamlAuth;
|
||||
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
|
||||
use OneLogin\Saml2\Error;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\Logout\SamlLogoutHandler
|
||||
*/
|
||||
class SamlLogoutHandlerTest extends TestCase
|
||||
{
|
||||
public function testLogout()
|
||||
{
|
||||
$auth = $this->getMockBuilder(SamlAuth::class)->disableOriginalConstructor()->getMock();
|
||||
$auth->expects($this->once())->method('processSLO')->willThrowException(new Error('blub'));
|
||||
$auth->expects($this->once())->method('getSLOurl')->willReturn('');
|
||||
|
||||
$request = new Request();
|
||||
$response = new Response();
|
||||
$token = new SamlToken([]);
|
||||
|
||||
$sut = new SamlLogoutHandler($auth);
|
||||
$sut->logout($request, $response, $token);
|
||||
}
|
||||
|
||||
public function testLogoutWithLogoutUrl()
|
||||
{
|
||||
$auth = $this->getMockBuilder(SamlAuth::class)->disableOriginalConstructor()->getMock();
|
||||
$auth->expects($this->once())->method('processSLO')->willThrowException(new Error('blub'));
|
||||
$auth->expects($this->once())->method('getSLOurl')->willReturn('/logout');
|
||||
$auth->expects($this->once())->method('logout')->willReturnCallback(function () {
|
||||
$args = func_get_args();
|
||||
self::assertEquals(null, $args[0]);
|
||||
self::assertEquals([], $args[1]);
|
||||
self::assertEquals('tony', $args[2]);
|
||||
self::assertEquals('foo-bar', $args[3]);
|
||||
});
|
||||
|
||||
$request = new Request();
|
||||
$response = new Response();
|
||||
$token = new SamlToken([]);
|
||||
$token->setUser((new User())->setUsername('tony'));
|
||||
$token->setAttribute('sessionIndex', 'foo-bar');
|
||||
|
||||
$sut = new SamlLogoutHandler($auth);
|
||||
$sut->logout($request, $response, $token);
|
||||
}
|
||||
}
|
||||
111
tests/Saml/Provider/SamlProviderTest.php
Normal file
111
tests/Saml/Provider/SamlProviderTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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\Tests\Saml\Provider;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Saml\Provider\SamlProvider;
|
||||
use App\Saml\SamlTokenFactory;
|
||||
use App\Saml\User\SamlUserFactory;
|
||||
use App\Security\DoctrineUserProvider;
|
||||
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\User\ChainUserProvider;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\Provider\SamlProvider
|
||||
*/
|
||||
class SamlProviderTest extends TestCase
|
||||
{
|
||||
protected function getSamlProvider($mapping = null, $loadUser = false): SamlProvider
|
||||
{
|
||||
if (null === $mapping) {
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$Email', 'kimai' => 'email'],
|
||||
['saml' => '$title', 'kimai' => 'title'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => '',
|
||||
'mapping' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
if ($loadUser !== false) {
|
||||
$repository->expects($this->once())->method('loadUserByUsername')->willReturn($loadUser);
|
||||
}
|
||||
$userProvider = new ChainUserProvider([new DoctrineUserProvider($repository)]);
|
||||
$provider = new SamlProvider($repository, $userProvider, new SamlTokenFactory(), new SamlUserFactory($mapping));
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
public function testSupportsToken()
|
||||
{
|
||||
$sut = $this->getSamlProvider();
|
||||
self::assertFalse($sut->supports(new AnonymousToken('ads', 'ads')));
|
||||
self::assertFalse($sut->supports(new UsernamePasswordToken('ads', 'ads', 'asd')));
|
||||
self::assertTrue($sut->supports(new SamlToken([])));
|
||||
}
|
||||
|
||||
public function testAuthenticateHydratesUser()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setAuth(User::AUTH_SAML);
|
||||
|
||||
$token = new SamlToken([]);
|
||||
$token->setUser('foo1@example.com');
|
||||
$token->setAttributes([
|
||||
'Email' => ['foo@example.com'],
|
||||
'title' => ['Tralalala'],
|
||||
]);
|
||||
self::assertFalse($token->isAuthenticated());
|
||||
|
||||
$sut = $this->getSamlProvider(null, $user);
|
||||
$authToken = $sut->authenticate($token);
|
||||
|
||||
self::assertTrue($authToken->isAuthenticated());
|
||||
|
||||
/** @var User $tokenUser */
|
||||
$tokenUser = $authToken->getUser();
|
||||
|
||||
self::assertSame($user, $tokenUser);
|
||||
self::assertEquals('foo1@example.com', $tokenUser->getUsername());
|
||||
self::assertEquals('Tralalala', $tokenUser->getTitle());
|
||||
self::assertEquals('foo@example.com', $tokenUser->getEmail());
|
||||
}
|
||||
|
||||
public function testAuthenticatCreatesNewUser()
|
||||
{
|
||||
$token = new SamlToken([]);
|
||||
$token->setUser('foo1@example.com');
|
||||
$token->setAttributes([
|
||||
'Email' => ['foo@example.com'],
|
||||
'title' => ['Tralalala'],
|
||||
]);
|
||||
self::assertFalse($token->isAuthenticated());
|
||||
|
||||
$sut = $this->getSamlProvider(null);
|
||||
$authToken = $sut->authenticate($token);
|
||||
|
||||
self::assertTrue($authToken->isAuthenticated());
|
||||
|
||||
/** @var User $tokenUser */
|
||||
$tokenUser = $authToken->getUser();
|
||||
|
||||
self::assertEquals('foo1@example.com', $tokenUser->getUsername());
|
||||
self::assertEquals('Tralalala', $tokenUser->getTitle());
|
||||
self::assertEquals('foo@example.com', $tokenUser->getEmail());
|
||||
}
|
||||
}
|
||||
37
tests/Saml/SamlTokenFactoryTest.php
Normal file
37
tests/Saml/SamlTokenFactoryTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?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\Tests\Saml;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Saml\SamlTokenFactory;
|
||||
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\SamlTokenFactory
|
||||
*/
|
||||
class SamlTokenFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreateToken()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$factory = new SamlTokenFactory();
|
||||
$sut = $factory->createToken($user, ['foo' => 'bar', 'bar' => 'world'], ['ROLE_ADMIN', 'ROLE_TEST']);
|
||||
|
||||
self::assertInstanceOf(SamlToken::class, $sut);
|
||||
self::assertEquals('bar', $sut->getAttribute('foo'));
|
||||
self::assertEquals('world', $sut->getAttribute('bar'));
|
||||
self::assertEquals(['ROLE_ADMIN', 'ROLE_TEST'], $sut->getRoleNames());
|
||||
self::assertSame($user, $sut->getUser());
|
||||
self::assertEquals('foobar', $sut->getUsername());
|
||||
}
|
||||
}
|
||||
104
tests/Saml/Security/SamlAuthenticationSuccessHandlerTest.php
Normal file
104
tests/Saml/Security/SamlAuthenticationSuccessHandlerTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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\Tests\Saml\Security;
|
||||
|
||||
use App\Saml\Security\SamlAuthenticationSuccessHandler;
|
||||
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Http\HttpUtils;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\Security\SamlAuthenticationSuccessHandler
|
||||
*/
|
||||
class SamlAuthenticationSuccessHandlerTest extends TestCase
|
||||
{
|
||||
private $handler;
|
||||
|
||||
public function testWithAlwaysUseDefaultTargetPath()
|
||||
{
|
||||
$httpUtils = new HttpUtils($this->getUrlGenerator());
|
||||
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => true]);
|
||||
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'default_target_path', '/'));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('/login', 'http://localhost/relayed'), $this->getSamlToken());
|
||||
$this->assertTrue($response->isRedirect($defaultTargetPath));
|
||||
}
|
||||
|
||||
public function testRelayState()
|
||||
{
|
||||
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()), ['always_use_default_target_path' => false]);
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', 'http://localhost/relayed'), $this->getSamlToken());
|
||||
$this->assertTrue($response->isRedirect('http://localhost/relayed'));
|
||||
}
|
||||
|
||||
public function testWithoutRelayState()
|
||||
{
|
||||
$httpUtils = new HttpUtils($this->getUrlGenerator());
|
||||
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => false]);
|
||||
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'default_target_path', '/'));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest(), $this->getSamlToken());
|
||||
$this->assertTrue($response->isRedirect($defaultTargetPath));
|
||||
}
|
||||
|
||||
public function testRelayStateLoop()
|
||||
{
|
||||
$httpUtils = new HttpUtils($this->getUrlGenerator());
|
||||
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => false]);
|
||||
$loginPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'login_path', '/login'));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest($loginPath), $this->getSamlToken());
|
||||
$this->assertTrue(!$response->isRedirect($loginPath));
|
||||
}
|
||||
|
||||
private function getUrlGenerator()
|
||||
{
|
||||
$urlGenerator = $this->getMockBuilder('Symfony\Component\Routing\Generator\UrlGeneratorInterface')->getMock();
|
||||
$urlGenerator
|
||||
->expects($this->any())
|
||||
->method('generate')
|
||||
->will($this->returnCallback(function ($name) {
|
||||
return (string) $name;
|
||||
}))
|
||||
;
|
||||
|
||||
return $urlGenerator;
|
||||
}
|
||||
|
||||
private function getRequest($path = '/', $relayState = null)
|
||||
{
|
||||
$params = [];
|
||||
if (null !== $relayState) {
|
||||
$params['RelayState'] = $relayState;
|
||||
}
|
||||
|
||||
return Request::create($path, 'get', $params);
|
||||
}
|
||||
|
||||
private function getSamlToken()
|
||||
{
|
||||
$token = new SamlToken([]);
|
||||
$token->setAttributes(['foo' => 'bar']);
|
||||
$token->setUser('admin');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getOption($handler, $name, $default = null)
|
||||
{
|
||||
$reflection = new \ReflectionObject($handler);
|
||||
$options = $reflection->getProperty('options');
|
||||
$options->setAccessible(true);
|
||||
$arr = $options->getValue($handler);
|
||||
if (!is_array($arr) || !isset($arr[$name])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $arr[$name];
|
||||
}
|
||||
}
|
||||
45
tests/Saml/Security/SamlFactoryTest.php
Normal file
45
tests/Saml/Security/SamlFactoryTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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\Tests\Saml\Security;
|
||||
|
||||
use App\Saml\Security\SamlFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\ChildDefinition;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\Security\SamlFactory
|
||||
*/
|
||||
class SamlFactoryTest extends TestCase
|
||||
{
|
||||
public function testStaticValues()
|
||||
{
|
||||
$sut = new SamlFactory();
|
||||
self::assertEquals('kimai_saml', $sut->getKey());
|
||||
self::assertEquals('pre_auth', $sut->getPosition());
|
||||
}
|
||||
|
||||
public function testCreate()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$sut = new SamlFactory();
|
||||
$result = $sut->create($container, 'test', ['foo' => 'bar', 'login_path' => null, 'use_forward' => null], 'fosuserbundle', 'secured_area');
|
||||
|
||||
self::assertEquals([
|
||||
'security.authentication.provider.saml.test',
|
||||
'kimai.saml_listener.test',
|
||||
'secured_area'
|
||||
], $result);
|
||||
|
||||
$definition = $container->getDefinition('security.authentication.provider.saml.test');
|
||||
self::assertInstanceOf(ChildDefinition::class, $definition);
|
||||
self::assertCount(1, $definition->getArguments());
|
||||
}
|
||||
}
|
||||
184
tests/Saml/User/SamlUserFactoryTest.php
Normal file
184
tests/Saml/User/SamlUserFactoryTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?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\Tests\Saml\User;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Saml\User\SamlUserFactory;
|
||||
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Saml\User\SamlUserFactory
|
||||
*/
|
||||
class SamlUserFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreateUserThrowsExceptionOnMissingAttribute()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Missing user attribute: title');
|
||||
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$Email', 'kimai' => 'email'],
|
||||
['saml' => '$title', 'kimai' => 'title'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => '',
|
||||
'mapping' => []
|
||||
]
|
||||
];
|
||||
|
||||
$attributes = [
|
||||
'Email' => ['test@example.com'],
|
||||
];
|
||||
|
||||
$token = new SamlToken();
|
||||
$token->setAttributes($attributes);
|
||||
|
||||
$sut = new SamlUserFactory($mapping);
|
||||
$user = $sut->createUser($token);
|
||||
}
|
||||
|
||||
public function testCreateUserThrowsExceptionOnMissingAttributeInMultiple()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Missing user attribute: test');
|
||||
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$Email $test', 'kimai' => 'email'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => '',
|
||||
'mapping' => []
|
||||
]
|
||||
];
|
||||
|
||||
$attributes = [
|
||||
'Email' => ['test@example.com'],
|
||||
];
|
||||
|
||||
$token = new SamlToken();
|
||||
$token->setAttributes($attributes);
|
||||
|
||||
$sut = new SamlUserFactory($mapping);
|
||||
$user = $sut->createUser($token);
|
||||
}
|
||||
|
||||
public function testCreateUserThrowsExceptionOnInvalidMapping()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Invalid mapping field given: foo');
|
||||
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$Email', 'kimai' => 'email'],
|
||||
['saml' => '$Email', 'kimai' => 'foo'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => '',
|
||||
'mapping' => []
|
||||
]
|
||||
];
|
||||
|
||||
$attributes = [
|
||||
'Email' => ['test@example.com'],
|
||||
];
|
||||
|
||||
$token = new SamlToken();
|
||||
$token->setAttributes($attributes);
|
||||
|
||||
$sut = new SamlUserFactory($mapping);
|
||||
$user = $sut->createUser($token);
|
||||
}
|
||||
|
||||
public function testCreateUser()
|
||||
{
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$avatar', 'kimai' => 'avatar'],
|
||||
['saml' => '$Email', 'kimai' => 'email'],
|
||||
['saml' => 'A static super title', 'kimai' => 'title'],
|
||||
// double space between "$LastName $FOOO" on purpose!!!
|
||||
['saml' => '$FirstName $LastName $FOOO me', 'kimai' => 'alias'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => 'RoLeS',
|
||||
'mapping' => [
|
||||
['saml' => 'fooobar', 'kimai' => 'ROLE_ADMIN'],
|
||||
['saml' => 'ROLE_1', 'kimai' => 'ROLE_TEAMLEAD'],
|
||||
['saml' => 'ROLE_2', 'kimai' => 'ROLE_2'],
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$attributes = [
|
||||
'RoLeS' => ['ROLE_1', 'ROLE_2', 'ROLE_3'],
|
||||
'Email' => ['test@example.com'],
|
||||
'FOOO' => ['test', 'test2'],
|
||||
'FirstName' => ['Kevin'],
|
||||
'LastName' => ['Papst'],
|
||||
'avatar' => ['http://www.example.com/test.jpg'],
|
||||
];
|
||||
|
||||
$token = new SamlToken();
|
||||
$token->setUser('foo@example.com');
|
||||
$token->setAttributes($attributes);
|
||||
|
||||
$sut = new SamlUserFactory($mapping);
|
||||
$user = $sut->createUser($token);
|
||||
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
self::assertTrue($user->isEnabled());
|
||||
self::assertEquals('', $user->getPassword());
|
||||
self::assertEquals('test@example.com', $user->getEmail());
|
||||
self::assertEquals('foo@example.com', $user->getUsername());
|
||||
self::assertEquals('A static super title', $user->getTitle());
|
||||
self::assertEquals('Kevin Papst test me', $user->getAlias());
|
||||
self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_2', 'ROLE_USER'], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testCreateUserDoesOverwriteUsername()
|
||||
{
|
||||
$mapping = [
|
||||
'mapping' => [
|
||||
['saml' => '$avatar', 'kimai' => 'avatar'],
|
||||
['saml' => '$Email', 'kimai' => 'email'],
|
||||
['saml' => 'A static super title', 'kimai' => 'title'],
|
||||
['saml' => 'Mr. T', 'kimai' => 'username'],
|
||||
],
|
||||
'roles' => [
|
||||
'attribute' => null,
|
||||
'mapping' => []
|
||||
]
|
||||
];
|
||||
|
||||
$attributes = [
|
||||
'Email' => ['test@example.com'],
|
||||
'FOOO' => ['test', 'test2'],
|
||||
'avatar' => ['http://www.example.com/test.jpg'],
|
||||
];
|
||||
|
||||
$token = new SamlToken();
|
||||
$token->setUser('foo@example.com');
|
||||
$token->setAttributes($attributes);
|
||||
|
||||
$sut = new SamlUserFactory($mapping);
|
||||
$user = $sut->createUser($token);
|
||||
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
self::assertTrue($user->isEnabled());
|
||||
self::assertEquals('', $user->getPassword());
|
||||
self::assertEquals('test@example.com', $user->getEmail());
|
||||
self::assertEquals('foo@example.com', $user->getUsername());
|
||||
self::assertEquals('A static super title', $user->getTitle());
|
||||
self::assertEquals(['ROLE_USER'], $user->getRoles());
|
||||
}
|
||||
}
|
||||
126
tests/Security/DoctrineUserProviderTest.php
Normal file
126
tests/Security/DoctrineUserProviderTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?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\Tests\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\DoctrineUserProvider;
|
||||
use Hslavich\OneloginSamlBundle\Security\User\SamlUserInterface;
|
||||
use KevinPapst\AdminLTEBundle\Model\UserInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
|
||||
|
||||
/**
|
||||
* @covers \App\Security\DoctrineUserProvider
|
||||
*/
|
||||
class DoctrineUserProviderTest extends TestCase
|
||||
{
|
||||
public function testLoadUserByUsernameReturnsNullThrowsException()
|
||||
{
|
||||
$this->expectException(UsernameNotFoundException::class);
|
||||
$this->expectExceptionMessage('User "test" not found.');
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
$repository->expects($this->once())->method('loadUserByUsername')->willReturn(null);
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$sut->loadUserByUsername('test');
|
||||
}
|
||||
|
||||
public function testLoadUserByUsernameReturnsUser()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
$repository->expects($this->once())->method('loadUserByUsername')->willReturn($user);
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$actual = $sut->loadUserByUsername('test');
|
||||
|
||||
self::assertInstanceOf(User::class, $actual);
|
||||
self::assertSame($user, $actual);
|
||||
}
|
||||
|
||||
public function testSupportsClass()
|
||||
{
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
|
||||
self::assertTrue($sut->supportsClass(User::class));
|
||||
self::assertTrue($sut->supportsClass('App\Entity\User'));
|
||||
self::assertFalse($sut->supportsClass(UserInterface::class));
|
||||
self::assertFalse($sut->supportsClass(SamlUserInterface::class));
|
||||
self::assertFalse($sut->supportsClass(\FOS\UserBundle\Model\User::class));
|
||||
self::assertFalse($sut->supportsClass(TestUserEntity::class));
|
||||
}
|
||||
|
||||
public function testRefreshUserThrowsExceptionOnUnsupportedUserClass()
|
||||
{
|
||||
$this->expectException(UnsupportedUserException::class);
|
||||
$this->expectExceptionMessage('Expected an instance of App\Entity\User, but got "App\Tests\Security\TestUserEntity".');
|
||||
|
||||
$user = new TestUserEntity();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$actual = $sut->refreshUser($user);
|
||||
}
|
||||
|
||||
public function testRefreshUserThrowsExceptionOnNonFoundUser()
|
||||
{
|
||||
$this->expectException(UsernameNotFoundException::class);
|
||||
$this->expectExceptionMessage('User with ID "" could not be reloaded');
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
$repository->expects($this->once())->method('getUserById')->willReturn(null);
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$actual = $sut->refreshUser($user);
|
||||
}
|
||||
|
||||
public function testRefreshUserThrowsNoExceptionOnLdapUser()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
$user->setAuth(User::AUTH_LDAP);
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
$repository->expects($this->once())->method('getUserById')->willReturn($user);
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$actual = $sut->refreshUser($user);
|
||||
|
||||
self::assertSame($user, $actual);
|
||||
}
|
||||
|
||||
public function testRefreshUserReturnsUser()
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('foobar');
|
||||
|
||||
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
|
||||
$repository->expects($this->once())->method('getUserById')->willReturn($user);
|
||||
|
||||
$sut = new DoctrineUserProvider($repository);
|
||||
$actual = $sut->refreshUser($user);
|
||||
|
||||
self::assertInstanceOf(User::class, $actual);
|
||||
self::assertSame($user, $actual);
|
||||
self::assertTrue($user->isInternalUser());
|
||||
}
|
||||
}
|
||||
16
tests/Security/TestUserEntity.php
Normal file
16
tests/Security/TestUserEntity.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?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\Tests\Security;
|
||||
|
||||
use FOS\UserBundle\Model\User;
|
||||
|
||||
class TestUserEntity extends User
|
||||
{
|
||||
}
|
||||
@@ -85,4 +85,30 @@ class UserVoterTest extends AbstractVoterTest
|
||||
yield [$user, null, 'delete', $result];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestDataForAuthType
|
||||
*/
|
||||
public function testPasswordIsDeniedForNonInternalUser(string $authType, int $result)
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->addRole('ROLE_SUPER_ADMIN');
|
||||
|
||||
$subject = new User();
|
||||
$subject->setUsername('foo');
|
||||
$subject->addRole('ROLE_USER');
|
||||
$subject->setAuth($authType);
|
||||
|
||||
$this->testVote($user, $subject, 'password', $result);
|
||||
}
|
||||
|
||||
public function getTestDataForAuthType()
|
||||
{
|
||||
return [
|
||||
[User::AUTH_LDAP, VoterInterface::ACCESS_DENIED],
|
||||
[User::AUTH_INTERNAL, VoterInterface::ACCESS_GRANTED],
|
||||
[User::AUTH_SAML, VoterInterface::ACCESS_DENIED],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user