diff --git a/composer.json b/composer.json
index 7d1b1db0..273fb39a 100644
--- a/composer.json
+++ b/composer.json
@@ -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",
diff --git a/composer.lock b/composer.lock
index 2e64f1f8..55a2dd0c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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",
diff --git a/config/packages/security.yaml b/config/packages/security.yaml
index 3c07d11e..03f8f58a 100644
--- a/config/packages/security.yaml
+++ b/config/packages/security.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 }
diff --git a/config/services-saml.yaml b/config/services-saml.yaml
new file mode 100644
index 00000000..6942ea74
--- /dev/null
+++ b/config/services-saml.yaml
@@ -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
diff --git a/config/services.yaml b/config/services.yaml
index 05f40fa7..c7d79ab6 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -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%']
# ================================================================================
diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php
index 914dc443..b7d3ed67 100644
--- a/src/DependencyInjection/AppExtension.php
+++ b/src/DependencyInjection/AppExtension.php
@@ -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']);
diff --git a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php
index 84168297..72f7f4b5 100644
--- a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php
+++ b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php
@@ -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');
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index dc0f28d5..5146e142 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -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;
+ }
}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 6e23d532..ec7d9b98 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -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
*/
diff --git a/src/EventSubscriber/ResetPasswordSubscriber.php b/src/EventSubscriber/ResetPasswordSubscriber.php
new file mode 100644
index 00000000..5f83e37f
--- /dev/null
+++ b/src/EventSubscriber/ResetPasswordSubscriber.php
@@ -0,0 +1,44 @@
+ ['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())
+ );
+ }
+ }
+}
diff --git a/src/Kernel.php b/src/Kernel.php
index 1e957ab2..693af9cc 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -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');
+ }
}
diff --git a/src/Ldap/FormLoginLdapFactory.php b/src/Ldap/FormLoginLdapFactory.php
index 9eab591d..2cec0b8a 100644
--- a/src/Ldap/FormLoginLdapFactory.php
+++ b/src/Ldap/FormLoginLdapFactory.php
@@ -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;
}
diff --git a/src/Ldap/LdapUserHydrator.php b/src/Ldap/LdapUserHydrator.php
index 3099eab0..2b002ee1 100644
--- a/src/Ldap/LdapUserHydrator.php
+++ b/src/Ldap/LdapUserHydrator.php
@@ -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']);
}
diff --git a/src/Ldap/LdapUserProvider.php b/src/Ldap/LdapUserProvider.php
index debc3f54..f8fbb87a 100644
--- a/src/Ldap/LdapUserProvider.php
+++ b/src/Ldap/LdapUserProvider.php
@@ -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()));
}
diff --git a/src/Migrations/Version20200125123942.php b/src/Migrations/Version20200125123942.php
new file mode 100644
index 00000000..4526af2e
--- /dev/null
+++ b/src/Migrations/Version20200125123942.php
@@ -0,0 +1,55 @@
+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');
+ }
+}
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 3f003dc1..5817203a 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -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
diff --git a/src/Saml/Controller/SamlController.php b/src/Saml/Controller/SamlController.php
new file mode 100644
index 00000000..9352ca5c
--- /dev/null
+++ b/src/Saml/Controller/SamlController.php
@@ -0,0 +1,86 @@
+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.');
+ }
+}
diff --git a/src/Saml/Logout/SamlLogoutHandler.php b/src/Saml/Logout/SamlLogoutHandler.php
new file mode 100644
index 00000000..ce9690ce
--- /dev/null
+++ b/src/Saml/Logout/SamlLogoutHandler.php
@@ -0,0 +1,56 @@
+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);
+ }
+ }
+ }
+}
diff --git a/src/Saml/Provider/SamlProvider.php b/src/Saml/Provider/SamlProvider.php
new file mode 100644
index 00000000..4059e4cd
--- /dev/null
+++ b/src/Saml/Provider/SamlProvider.php
@@ -0,0 +1,90 @@
+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;
+ }
+}
diff --git a/src/Saml/SamlAuth.php b/src/Saml/SamlAuth.php
new file mode 100644
index 00000000..b6ffd299
--- /dev/null
+++ b/src/Saml/SamlAuth.php
@@ -0,0 +1,26 @@
+getMasterRequest() && $request->getMasterRequest()->isFromTrustedProxy()) {
+ Utils::setProxyVars(true);
+ }
+ }
+}
diff --git a/src/Saml/SamlTokenFactory.php b/src/Saml/SamlTokenFactory.php
new file mode 100644
index 00000000..6be5049e
--- /dev/null
+++ b/src/Saml/SamlTokenFactory.php
@@ -0,0 +1,28 @@
+setUser($user);
+ $token->setAttributes($attributes);
+
+ return $token;
+ }
+}
diff --git a/src/Saml/Security/SamlAuthenticationSuccessHandler.php b/src/Saml/Security/SamlAuthenticationSuccessHandler.php
new file mode 100644
index 00000000..0374d76c
--- /dev/null
+++ b/src/Saml/Security/SamlAuthenticationSuccessHandler.php
@@ -0,0 +1,30 @@
+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);
+ }
+}
diff --git a/src/Saml/Security/SamlFactory.php b/src/Saml/Security/SamlFactory.php
new file mode 100644
index 00000000..70fcb94e
--- /dev/null
+++ b/src/Saml/Security/SamlFactory.php
@@ -0,0 +1,77 @@
+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)]);
+ }
+ }
+}
diff --git a/src/Saml/User/SamlUserFactory.php b/src/Saml/User/SamlUserFactory.php
new file mode 100644
index 00000000..4ab1c354
--- /dev/null
+++ b/src/Saml/User/SamlUserFactory.php
@@ -0,0 +1,116 @@
+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;
+ }
+}
diff --git a/src/Security/DoctrineUserProvider.php b/src/Security/DoctrineUserProvider.php
new file mode 100644
index 00000000..852cc468
--- /dev/null
+++ b/src/Security/DoctrineUserProvider.php
@@ -0,0 +1,77 @@
+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';
+ }
+}
diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php
index 2714f385..596d24dc 100644
--- a/src/Voter/UserVoter.php
+++ b/src/Voter/UserVoter.php
@@ -67,6 +67,10 @@ class UserVoter extends AbstractVoter
}
return $this->hasRolePermission($user, 'delete_user');
+ } elseif ($attribute === 'password') {
+ if (!$subject->isInternalUser()) {
+ return false;
+ }
}
$permission = $attribute;
diff --git a/symfony.lock b/symfony.lock
index e1011295..99c400a4 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -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"
},
diff --git a/templates/bundles/FOSUserBundle/Security/login.html.twig b/templates/bundles/FOSUserBundle/Security/login.html.twig
index ab34340c..4478476f 100644
--- a/templates/bundles/FOSUserBundle/Security/login.html.twig
+++ b/templates/bundles/FOSUserBundle/Security/login.html.twig
@@ -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 %}
+
+ {{ saml.title|trans }}
+
+
+ {% endif %}
{% endblock %}
\ No newline at end of file
diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php
index 46c01153..8cd6ab17 100644
--- a/tests/DependencyInjection/AppExtensionTest.php
+++ b/tests/DependencyInjection/AppExtensionTest.php
@@ -57,6 +57,9 @@ class AppExtensionTest extends TestCase
'data_dir' => '/tmp/',
'plugin_dir' => '/tmp/',
'timesheet' => [],
+ 'saml' => [
+ 'connection' => []
+ ]
]
];
}
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index 29d476f5..e90e8b79 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -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' => []
+ ],
]
];
diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php
index 3e3348c4..cb76d7ed 100644
--- a/tests/Entity/UserTest.php
+++ b/tests/Entity/UserTest.php
@@ -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'));
+ }
}
diff --git a/tests/EventSubscriber/ResetPasswordSubscriberTest.php b/tests/EventSubscriber/ResetPasswordSubscriberTest.php
new file mode 100644
index 00000000..6a03f325
--- /dev/null
+++ b/tests/EventSubscriber/ResetPasswordSubscriberTest.php
@@ -0,0 +1,89 @@
+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],
+ ];
+ }
+}
diff --git a/tests/Ldap/FormLoginLdapFactoryTest.php b/tests/Ldap/FormLoginLdapFactoryTest.php
index f4d821c9..f0c7e646 100644
--- a/tests/Ldap/FormLoginLdapFactoryTest.php
+++ b/tests/Ldap/FormLoginLdapFactoryTest.php
@@ -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']);
diff --git a/tests/Ldap/LdapManagerTest.php b/tests/Ldap/LdapManagerTest.php
index c855c2fb..72cc0ae0 100644
--- a/tests/Ldap/LdapManagerTest.php
+++ b/tests/Ldap/LdapManagerTest.php
@@ -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);
diff --git a/tests/Ldap/LdapUserProviderTest.php b/tests/Ldap/LdapUserProviderTest.php
index fd155773..2e3b9966 100644
--- a/tests/Ldap/LdapUserProviderTest.php
+++ b/tests/Ldap/LdapUserProviderTest.php
@@ -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);
}
}
diff --git a/tests/Mocks/Saml/SamlAuthFactory.php b/tests/Mocks/Saml/SamlAuthFactory.php
new file mode 100644
index 00000000..8223c71e
--- /dev/null
+++ b/tests/Mocks/Saml/SamlAuthFactory.php
@@ -0,0 +1,84 @@
+ [
+ '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);
+ }
+}
diff --git a/tests/Saml/Controller/SamlControllerTest.php b/tests/Saml/Controller/SamlControllerTest.php
new file mode 100644
index 00000000..6f3b248d
--- /dev/null
+++ b/tests/Saml/Controller/SamlControllerTest.php
@@ -0,0 +1,45 @@
+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();
+ }
+}
diff --git a/tests/Saml/Logout/SamlLogoutHandlerTest.php b/tests/Saml/Logout/SamlLogoutHandlerTest.php
new file mode 100644
index 00000000..a4b5cb3d
--- /dev/null
+++ b/tests/Saml/Logout/SamlLogoutHandlerTest.php
@@ -0,0 +1,62 @@
+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);
+ }
+}
diff --git a/tests/Saml/Provider/SamlProviderTest.php b/tests/Saml/Provider/SamlProviderTest.php
new file mode 100644
index 00000000..bf814e12
--- /dev/null
+++ b/tests/Saml/Provider/SamlProviderTest.php
@@ -0,0 +1,111 @@
+ [
+ ['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());
+ }
+}
diff --git a/tests/Saml/SamlTokenFactoryTest.php b/tests/Saml/SamlTokenFactoryTest.php
new file mode 100644
index 00000000..bce06881
--- /dev/null
+++ b/tests/Saml/SamlTokenFactoryTest.php
@@ -0,0 +1,37 @@
+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());
+ }
+}
diff --git a/tests/Saml/Security/SamlAuthenticationSuccessHandlerTest.php b/tests/Saml/Security/SamlAuthenticationSuccessHandlerTest.php
new file mode 100644
index 00000000..ebe268ec
--- /dev/null
+++ b/tests/Saml/Security/SamlAuthenticationSuccessHandlerTest.php
@@ -0,0 +1,104 @@
+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];
+ }
+}
diff --git a/tests/Saml/Security/SamlFactoryTest.php b/tests/Saml/Security/SamlFactoryTest.php
new file mode 100644
index 00000000..b948c643
--- /dev/null
+++ b/tests/Saml/Security/SamlFactoryTest.php
@@ -0,0 +1,45 @@
+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());
+ }
+}
diff --git a/tests/Saml/User/SamlUserFactoryTest.php b/tests/Saml/User/SamlUserFactoryTest.php
new file mode 100644
index 00000000..6d0177c0
--- /dev/null
+++ b/tests/Saml/User/SamlUserFactoryTest.php
@@ -0,0 +1,184 @@
+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());
+ }
+}
diff --git a/tests/Security/DoctrineUserProviderTest.php b/tests/Security/DoctrineUserProviderTest.php
new file mode 100644
index 00000000..085d149c
--- /dev/null
+++ b/tests/Security/DoctrineUserProviderTest.php
@@ -0,0 +1,126 @@
+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());
+ }
+}
diff --git a/tests/Security/TestUserEntity.php b/tests/Security/TestUserEntity.php
new file mode 100644
index 00000000..cc3e9baf
--- /dev/null
+++ b/tests/Security/TestUserEntity.php
@@ -0,0 +1,16 @@
+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],
+ ];
+ }
}
diff --git a/var/data/kimai_test.sqlite b/var/data/kimai_test.sqlite
index a00aaead..e64ef10f 100644
Binary files a/var/data/kimai_test.sqlite and b/var/data/kimai_test.sqlite differ