added support for saml login (#1408)

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

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View 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

View File

@@ -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%']
# ================================================================================

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\EventSubscriber;
use App\Entity\User;
use FOS\UserBundle\Event\GetResponseNullableUserEvent;
use FOS\UserBundle\FOSUserEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Makes sure that only internally registered users can reset their password.
*/
class ResetPasswordSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
FOSUserEvents::RESETTING_SEND_EMAIL_INITIALIZE => ['onInitializeResetPassword', 200]
];
}
public function onInitializeResetPassword(GetResponseNullableUserEvent $event)
{
$user = $event->getUser();
if (!($user instanceof User)) {
return;
}
// that is not nice :-D
if (!$user->isInternalUser()) {
throw new AccessDeniedHttpException(
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUsername(), $user->getAuth())
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds a column to the user table to identify authenticator
*
* @version 1.8
*/
final class Version20200125123942 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds a column to the user table to identify authenticator';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->addColumn('auth', 'string', ['notnull' => false, 'length' => 20]);
}
public function down(Schema $schema): void
{
$users = $schema->getTable('kimai2_users');
$users->dropColumn('auth');
}
}

View File

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

View File

@@ -0,0 +1,86 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\Controller;
use App\Saml\SamlAuth;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
/**
* @Route(path="/saml")
*/
final class SamlController extends AbstractController
{
/**
* @var SamlAuth
*/
private $oneLoginAuth;
public function __construct(SamlAuth $oneLoginAuth)
{
$this->oneLoginAuth = $oneLoginAuth;
}
/**
* @Route(path="/login", name="saml_login")
*/
public function loginAction(Request $request)
{
$session = $request->getSession();
$authErrorKey = Security::AUTHENTICATION_ERROR;
if ($request->attributes->has($authErrorKey)) {
$error = $request->attributes->get($authErrorKey);
} elseif (null !== $session && $session->has($authErrorKey)) {
$error = $session->get($authErrorKey);
$session->remove($authErrorKey);
} else {
$error = null;
}
if ($error) {
throw new \RuntimeException($error->getMessage());
}
$this->oneLoginAuth->login($session->get('_security.main.target_path'));
}
/**
* @Route(path="/metadata", name="saml_metadata")
*/
public function metadataAction()
{
$metadata = $this->oneLoginAuth->getSettings()->getSPMetadata();
$response = new Response($metadata);
$response->headers->set('Content-Type', 'xml');
return $response;
}
/**
* @Route(path="/acs", name="saml_acs")
*/
public function assertionConsumerServiceAction()
{
throw new \RuntimeException('You must configure the check path in your firewall.');
}
/**
* @Route(path="/logout", name="saml_logout")
*/
public function logoutAction()
{
throw new \RuntimeException('You must configure the logout path in your firewall.');
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\Logout;
use App\Saml\SamlAuth;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use OneLogin\Saml2\Error;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
final class SamlLogoutHandler implements LogoutHandlerInterface
{
/**
* @var SamlAuth
*/
private $samlAuth;
public function __construct(SamlAuth $samlAuth)
{
$this->samlAuth = $samlAuth;
}
/**
* This method is called by the LogoutListener when a user has requested
* to be logged out. Usually, you would unset session variables, or remove
* cookies, etc.
*
* @param Request $request
* @param Response $response
* @param TokenInterface $token
*/
public function logout(Request $request, Response $response, TokenInterface $token)
{
if (!$token instanceof SamlTokenInterface) {
return;
}
try {
$this->samlAuth->processSLO();
} catch (Error $e) {
if (!empty($this->samlAuth->getSLOurl())) {
$sessionIndex = $token->hasAttribute('sessionIndex') ? $token->getAttribute('sessionIndex') : null;
$this->samlAuth->logout(null, [], $token->getUsername(), $sessionIndex);
}
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\Provider;
use App\Repository\UserRepository;
use App\Saml\SamlTokenFactory;
use App\Saml\User\SamlUserFactory;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class SamlProvider implements AuthenticationProviderInterface
{
/**
* @var UserProviderInterface
*/
private $userProvider;
/**
* @var SamlUserFactory
*/
private $userFactory;
/**
* @var SamlTokenFactory
*/
private $tokenFactory;
/**
* @var UserRepository
*/
private $repository;
public function __construct(UserRepository $repository, UserProviderInterface $userProvider, SamlTokenFactory $tokenFactory, SamlUserFactory $userFactory)
{
$this->repository = $repository;
$this->userProvider = $userProvider;
$this->tokenFactory = $tokenFactory;
$this->userFactory = $userFactory;
}
public function authenticate(TokenInterface $token)
{
$user = null;
/** @var ChainUserProvider $p */
$p = $this->userProvider;
try {
$user = $this->userProvider->loadUserByUsername($token->getUsername());
} catch (UsernameNotFoundException $e) {
}
try {
if (null === $user) {
$user = $this->userFactory->createUser($token);
} else {
$this->userFactory->hydrateUser($user, $token);
}
$this->repository->saveUser($user);
} catch (\Exception $ex) {
throw new AuthenticationException(
sprintf('Failed creating or hydrating user "%s": %s', $token->getUsername(), $ex->getMessage())
);
}
if ($user) {
$authenticatedToken = $this->tokenFactory->createToken($user, $token->getAttributes(), $user->getRoles());
$authenticatedToken->setAuthenticated(true);
return $authenticatedToken;
}
throw new AuthenticationException('The authentication failed.');
}
public function supports(TokenInterface $token)
{
return $token instanceof SamlTokenInterface;
}
}

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

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Utils;
use Symfony\Component\HttpFoundation\RequestStack;
class SamlAuth extends Auth
{
public function __construct(RequestStack $request, array $settings = null)
{
parent::__construct($settings);
if (null !== $request->getMasterRequest() && $request->getMasterRequest()->isFromTrustedProxy()) {
Utils::setProxyVars(true);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenFactoryInterface;
final class SamlTokenFactory implements SamlTokenFactoryInterface
{
/**
* {@inheritdoc}
*/
public function createToken($user, array $attributes, array $roles)
{
$token = new SamlToken($roles);
$token->setUser($user);
$token->setAttributes($attributes);
return $token;
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSuccessHandler
{
protected function determineTargetUrl(Request $request)
{
if ($this->options['always_use_default_target_path']) {
return $this->options['default_target_path'];
}
$relayState = $request->get('RelayState');
if (null !== $relayState && $relayState !== $this->httpUtils->generateUri($request, $this->options['login_path'])) {
return $relayState;
}
return parent::determineTargetUrl($request);
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\Security;
use App\Saml\Logout\SamlLogoutHandler;
use App\Saml\Provider\SamlProvider;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class SamlFactory extends AbstractFactory
{
public function __construct()
{
$this->addOption('check_path', 'saml_acs');
$this->addOption('failure_path', 'fos_user_security_login');
$this->addOption('success_handler', SamlAuthenticationSuccessHandler::class);
$this->defaultFailureHandlerOptions['login_path'] = 'saml_login';
}
protected function isRememberMeAware($config)
{
return false;
}
public function getPosition()
{
return 'pre_auth';
}
public function getKey()
{
return 'kimai_saml';
}
protected function getListenerId()
{
return 'kimai.saml_listener';
}
protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId)
{
$providerId = 'security.authentication.provider.saml.' . $id;
$definition = $container->setDefinition($providerId, new ChildDefinition(SamlProvider::class));
$definition->replaceArgument(1, new Reference($userProviderId));
return $providerId;
}
protected function createListener($container, $id, $config, $userProvider)
{
$listenerId = parent::createListener($container, $id, $config, $userProvider);
$this->createLogoutHandler($container, $id, $config);
return $listenerId;
}
private function createLogoutHandler(ContainerBuilder $container, $id, $config)
{
if ($container->hasDefinition('security.logout_listener.' . $id)) {
$logoutListener = $container->getDefinition('security.logout_listener.' . $id);
$container
->setDefinition(SamlLogoutHandler::class, new ChildDefinition('saml.security.http.logout'))
->replaceArgument(2, array_intersect_key($config, $this->options));
$logoutListener->addMethodCall('addHandler', [new Reference(SamlLogoutHandler::class)]);
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Saml\User;
use App\Entity\User;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface;
final class SamlUserFactory implements SamlUserFactoryInterface
{
/**
* @var array
*/
private $mapping;
/**
* @var string
*/
private $groupAttribute;
/**
* @var array
*/
private $groupMapping;
public function __construct(array $attributes)
{
$this->mapping = $attributes['mapping'];
$this->groupAttribute = $attributes['roles']['attribute'];
$this->groupMapping = $attributes['roles']['mapping'];
}
public function createUser(SamlTokenInterface $token)
{
$user = new User();
$user->setEnabled(true);
$user->setUsername($token->getUsername());
$this->hydrateUser($user, $token);
return $user;
}
public function hydrateUser(User $user, SamlTokenInterface $token): void
{
// extract user roles from a special saml attribute
if (!empty($this->groupAttribute) && $token->hasAttribute($this->groupAttribute)) {
$groupMap = [];
foreach ($this->groupMapping as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$groupMap[$attribute] = $field;
}
$roles = [];
$samlGroups = $token->getAttribute($this->groupAttribute);
foreach ($samlGroups as $groupName) {
if (array_key_exists($groupName, $groupMap)) {
$roles[] = $groupMap[$groupName];
}
}
$user->setRoles($roles);
}
foreach ($this->mapping as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$value = $this->getPropertyValue($token, $attribute);
$setter = 'set' . ucfirst($field);
if (method_exists($user, $setter)) {
$user->$setter($value);
} else {
throw new \RuntimeException('Invalid mapping field given: ' . $field);
}
}
// fill them after hydrating account, so they can't be overwritten
$user->setUsername($token->getUsername());
$user->setPassword('');
$user->setAuth(User::AUTH_SAML);
}
private function getPropertyValue(SamlTokenInterface $token, $attribute)
{
$results = [];
$attributes = $token->getAttributes();
$parts = explode(' ', $attribute);
foreach ($parts as $part) {
if (empty(trim($part))) {
continue;
}
if ($part[0] === '$') {
$key = substr($part, 1);
if (!isset($attributes[$key])) {
throw new \RuntimeException('Missing user attribute: ' . $key);
}
$results[] = $attributes[$key][0];
} else {
$results[] = $part;
}
}
if (!empty($results)) {
return implode(' ', $results);
}
return $attribute;
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface as SecurityUserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class DoctrineUserProvider implements UserProviderInterface
{
/**
* @var UserRepository
*/
private $repository;
public function __construct(UserRepository $repository)
{
$this->repository = $repository;
}
/**
* {@inheritdoc}
*/
public function loadUserByUsername($username)
{
$user = null;
try {
/** @var User $user */
$user = $this->repository->loadUserByUsername($username);
} catch (\Exception $ex) {
}
if (null === $user) {
throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
}
return $user;
}
/**
* {@inheritdoc}
*/
public function refreshUser(SecurityUserInterface $user)
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Expected an instance of %s, but got "%s".', User::class, get_class($user)));
}
/** @var User $reloadedUser */
$reloadedUser = $this->repository->getUserById($user->getId());
if (null === $reloadedUser) {
throw new UsernameNotFoundException(sprintf('User with ID "%s" could not be reloaded.', $user->getId()));
}
return $reloadedUser;
}
/**
* {@inheritdoc}
*/
public function supportsClass($class)
{
return $class === User::class || $class === 'App\Entity\User';
}
}

View File

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

View File

@@ -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"
},

View File

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

View File

@@ -57,6 +57,9 @@ class AppExtensionTest extends TestCase
'data_dir' => '/tmp/',
'plugin_dir' => '/tmp/',
'timesheet' => [],
'saml' => [
'connection' => []
]
]
];
}

View File

@@ -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' => []
],
]
];

View File

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

View 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],
];
}
}

View File

@@ -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']);

View File

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

View File

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

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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());
}
}

View 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());
}
}

View 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];
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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
{
}

View File

@@ -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.