Refactor authentication system (#2602)

Make auth configuration available via UI, remove FOSUserBundle and SAML-Bundle dependency
This commit is contained in:
Kevin Papst
2021-06-10 15:34:13 +02:00
committed by GitHub
parent 286b63e2c8
commit 7f20cb045c
155 changed files with 5590 additions and 1802 deletions

View File

@@ -12,10 +12,19 @@ Perform EACH version specific task between your version and the new one, otherwi
**ATTENTION**
- This release bumps the minimum required [PHP version to 7.3](https://www.kimai.org/blog/2021/php8-support-php72-dropped/)
- Self-registration is disabled by default
- Self-registration now always requires email confirmation
- All plugins that use own databases need to be updated as well
**LDAP & SAML**
- SAML users need to activate it by setting the new `kimai.saml.activate: true` config
- LDAP and SAML users need to remove the complete `security` section from their `local.yaml`
- Please verify your config with the [LDAP](https://www.kimai.org/documentation/ldap.html) and [SAML](https://www.kimai.org/documentation/saml.html) documentation
**DEVELOPER**
PHP 8 compatibility forced to upgrade MANY libraries, including but not limited to:
- Removed FOSUserBundle and hslavich/oneloginsaml
- Doctrine Migrations, whose new major version forces the plugin updates
- Gedmo v3 (which include BC breaks in definitions)
- Doctrine DBAL and others, which required PHP 7.3 as well

View File

@@ -27,11 +27,9 @@
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.0",
"handcraftedinthealps/rest-routing-bundle": "^1.0",
"hslavich/oneloginsaml-bundle": "^1.4",
"jms/metadata": "^2.0",
"jms/serializer-bundle": "^3.9",
"kevinpapst/adminlte-bundle": "^3.3",
"kimai/user-bundle": "^2.0",
"kevinpapst/adminlte-bundle": "dev-master",
"laravolt/avatar": "^4.0",
"league/csv": "^9.4",
"league/html-to-markdown": "^5.0",

1000
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@ return [
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
KevinPapst\AdminLTEBundle\AdminLTEBundle::class => ['all' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
FOS\UserBundle\FOSUserBundle::class => ['all' => true],
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],

View File

@@ -1,15 +0,0 @@
fos_user:
db_driver: orm
firewall_name: secured_area
user_class: App\Entity\User
from_email:
address: '%env(MAILER_FROM)%'
sender_name: "Kimai 2"
registration:
confirmation:
enabled: false
resetting:
retry_ttl: 7200
token_ttl: 86400
service:
mailer: App\Mail\UserMails

View File

@@ -10,16 +10,11 @@ jms_serializer:
xml_serialization:
format_output: '%kernel.debug%'
metadata:
directories:
FOSUB:
namespace_prefix: "FOS\\UserBundle"
path: "%kernel.root_dir%/../config/serializer/FOS/UserBundle"
warmup:
paths:
included:
- "%kernel.root_dir%/Entity/"
- "%kernel.root_dir%/API/Model/"
- "%kernel.root_dir%/../vendor/kimai/user-bundle/Model"
excluded: []
property_naming:
id: 'jms_serializer.identical_property_naming_strategy'

View File

@@ -5,7 +5,7 @@ security:
providers:
chain_provider:
chain:
providers: [kimai_internal]
providers: [kimai_ldap,kimai_internal]
kimai_ldap:
id: App\Ldap\LdapUserProvider
kimai_internal:
@@ -17,6 +17,8 @@ security:
security: false
secured_area:
kimai_saml: ~
kimai_ldap: ~
pattern: ^/
user_checker: App\Security\UserChecker
anonymous: true

View File

@@ -12,7 +12,16 @@ app.api:
type: rest
prefix: /api
security:
auth:
resource: '../../src/Controller/Auth/'
type: annotation
prefix: /auth
security:
resource: '../../src/Controller/Security/'
type: annotation
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'

View File

@@ -1,12 +0,0 @@
# Expose security related features like login and logout
fos_user_security:
prefix: /{_locale}
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
# The features "user registration" and "password-reset" are enabled by default.
#
# You can disable them by setting the config keys in file "config/kimai.yaml":
# - kimai.user.registration: false
# - kimai.user.password_reset: false
#
# The routes for these functions are added dynamically in src/Kernel.php

View File

@@ -1,35 +0,0 @@
FOS\UserBundle\Model\User:
exclusion_policy: All
properties:
username:
include: true
groups: [Default]
enabled:
include: true
groups: [Default]
roles:
type: array<string>
include: true
groups: [User_Entity]
groups:
exclude: true
email:
exclude: true
emailCanonical:
exclude: true
usernameCanonical:
exclude: true
password:
exclude: true
plainPassword:
exclude: true
registeredAt:
exclude: true
lastLogin:
exclude: true
confirmationToken:
exclude: true
passwordRequestedAt:
exclude: true
salt:
exclude: true

View File

@@ -1,32 +1,18 @@
# ================================================================================
# SAML Services
# ================================================================================
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
class: App\Saml\Firewall\SamlListener
parent: security.authentication.listener.abstract
abstract: true
calls:
- [setOneLoginAuth, ["@onelogin_auth"]]
- [setAuth, ['@App\Saml\SamlAuthFactory']]
App\Saml\Provider\SamlProvider:
arguments: ['@App\Repository\UserRepository', '', '@App\Saml\SamlTokenFactory', '@App\Saml\User\SamlUserFactory']
arguments: ['@App\Repository\UserRepository', '', '@App\Saml\SamlTokenFactory', '@App\Saml\User\SamlUserFactory', '@App\Configuration\SystemConfiguration']
App\Saml\Security\SamlAuthenticationSuccessHandler:
parent: security.authentication.success_handler

View File

@@ -32,6 +32,9 @@ services:
# APPLICATION CORE
# ================================================================================
security.user.provider.chain:
class: App\Security\KimaiUserProvider
App\EventSubscriber\RedirectToLocaleSubscriber:
arguments: ['@router', '%app_locales%', '%locale%']
@@ -57,9 +60,6 @@ services:
arguments:
$settings: '%kimai.theme%'
App\Configuration\LdapConfiguration:
arguments: ['%kimai.ldap%']
App\Utils\MPdfConverter:
arguments: ['%kernel.cache_dir%']

View File

@@ -25,5 +25,8 @@ services:
# required for the importer command test
App\Importer\ImporterService:
public: true
arguments:
[ '@App\Customer\CustomerService', '@App\Project\ProjectService' ]
arguments: ['@App\Customer\CustomerService', '@App\Project\ProjectService']
App\User\UserService:
public: true
arguments: ['@App\Repository\UserRepository', '@event_dispatcher', '@validator', '@App\Configuration\SystemConfiguration', '@security.password_encoder']

View File

@@ -1,16 +0,0 @@
App\Entity\User:
properties:
roles:
- App\Validator\Constraints\Role: { groups: [RolesUpdate] }
username:
- NotBlank: { groups: [Registration, UserCreate, Profile] }
- Length: { min: 2, max: 60, groups: [Registration, UserCreate, Profile] }
email:
- NotBlank: { groups: [Registration, UserCreate, Profile] }
- Email: { groups: [Registration, UserCreate, Profile] }
plainPassword:
- NotBlank: { groups: [Registration, PasswordUpdate, UserCreate] }
- Length: { min: 8, max: 60, groups: [Registration, PasswordUpdate, UserCreate] }
plainApiToken:
- NotBlank: { groups: [ApiTokenUpdate] }
- Length: { min: 8, max: 60, groups: [ApiTokenUpdate] }

View File

@@ -25,6 +25,7 @@
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test"/>
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
<env name="MAILER_URL" value="null://null"/>
<env name="MAILER_FROM" value="kimai@example.com"/>
<!--
REINSTALL THE TEST DATABASE, AFTER CHANGING STRUCTURE?

View File

@@ -48,8 +48,9 @@ abstract class AbstractResetCommand extends Command
protected function configure()
{
$this
->setName('kimai:reset-' . $this->getEnvName())
->setDescription('Resets the dev environment')
->setName('kimai:reset:' . $this->getEnvName())
->setAliases(['kimai:reset-' . $this->getEnvName()])
->setDescription('Resets the "' . $this->getEnvName() . '" environment')
->setHelp(
<<<EOT
This command will drop and re-create the database and its schemas, load data and clear the cache.

View File

@@ -0,0 +1,69 @@
<?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\Command;
use App\Entity\User;
use App\User\UserService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractRoleCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('role', InputArgument::OPTIONAL, 'The role'),
new InputOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role'),
]);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$role = $input->getArgument('role');
$super = (true === $input->getOption('super'));
if (null !== $role && $super) {
throw new \InvalidArgumentException('You can pass either the role or the --super option (but not both simultaneously).');
}
if (null === $role && !$super) {
throw new \RuntimeException('Not enough arguments, pass a role or use --super.');
}
$user = $this->userService->findUserByUsernameOrThrowException($username);
$this->executeRoleCommand($this->userService, new SymfonyStyle($input, $output), $user, $super, $role);
return 0;
}
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role);
}

View File

@@ -0,0 +1,70 @@
<?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\Command;
use App\User\UserService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ActivateUserCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:user:activate')
->setAliases(['fos:user:activate'])
->setDescription('Activate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
<info>php %command.full_name% susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$user = $this->userService->findUserByUsernameOrThrowException($username);
$io = new SymfonyStyle($input, $output);
if (!$user->isEnabled()) {
$user->setEnabled(true);
$this->userService->updateUser($user);
$io->success(sprintf('User "%s" has been activated.', $username));
} else {
$io->warning(sprintf('User "%s" is already active.', $username));
}
return 0;
}
}

View File

@@ -0,0 +1,80 @@
<?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\Command;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ChangePasswordCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
protected function configure()
{
$this
->setName('kimai:user:password')
->setAliases(['fos:user:change-password'])
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::REQUIRED, 'The password'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:password</info> command changes the password of a user:
<info>php %command.full_name% matthieu</info>
This interactive shell will first ask you for a password.
You can alternatively specify the password as a second argument:
<info>php %command.full_name% susan_super newpassword</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$password = $input->getArgument('password');
$user = $this->userService->findUserByUsernameOrThrowException($username);
$io = new CommandStyle($input, $output);
try {
$user->setPlainPassword($password);
$this->userService->updateUser($user, ['PasswordUpdate']);
$io->success(sprintf('Changed password for user "%s".', $username));
} catch (ValidationFailedException $ex) {
$io->validationError($ex);
return 2;
}
return 0;
}
}

View File

@@ -10,39 +10,24 @@
namespace App\Command;
use App\Entity\User;
use Doctrine\Persistence\ManagerRegistry;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class CreateUserCommand extends Command
{
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
/**
* @var ManagerRegistry
*/
private $doctrine;
/**
* @var ValidatorInterface
*/
private $validator;
private $userService;
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
public function __construct(UserService $userService)
{
$this->encoder = $encoder;
$this->doctrine = $registry;
$this->validator = $validator;
parent::__construct();
$this->userService = $userService;
}
/**
@@ -53,7 +38,8 @@ final class CreateUserCommand extends Command
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this
->setName('kimai:create-user')
->setName('kimai:user:create')
->setAliases(['kimai:create-user'])
->setDescription('Create a new user')
->setHelp('This command allows you to create a new user.')
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
@@ -73,7 +59,7 @@ final class CreateUserCommand extends Command
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io = new CommandStyle($input, $output);
$username = $input->getArgument('username');
$email = $input->getArgument('email');
@@ -87,41 +73,18 @@ final class CreateUserCommand extends Command
$role = $role ?: User::DEFAULT_ROLE;
$user = new User();
$user->setUsername($username)
->setPlainPassword($password)
->setEmail($email)
->setEnabled(true)
->setRoles(explode(',', $role))
;
$errors = $this->validator->validate($user, null, ['Registration']);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$io->error(
$error->getPropertyPath()
. ' (' . (\is_array($value) ? implode(',', $value) : $value) . ')'
. "\n "
. $error->getMessage()
);
}
return 1;
}
$user = $this->userService->createNewUser();
$user->setUsername($username);
$user->setPlainPassword($password);
$user->setEmail($email);
$user->setEnabled(true);
$user->setRoles(explode(',', $role));
try {
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
$entityManager = $this->doctrine->getManager();
$entityManager->persist($user);
$entityManager->flush();
$io->success('Success! Created user: ' . $user->getUsername());
} catch (\Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage());
$this->userService->saveNewUser($user);
$io->success(sprintf('Success! Created user: %s', $username));
} catch (ValidationFailedException $ex) {
$io->validationError($ex);
return 2;
}

View File

@@ -0,0 +1,70 @@
<?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\Command;
use App\User\UserService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class DeactivateUserCommand extends Command
{
private $userService;
public function __construct(UserService $userService)
{
parent::__construct();
$this->userService = $userService;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:user:deactivate')
->setAliases(['fos:user:deactivate'])
->setDescription('Deactivate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp(
<<<'EOT'
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
<info>php %command.full_name% susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$user = $this->userService->findUserByUsernameOrThrowException($username);
$io = new SymfonyStyle($input, $output);
if ($user->isEnabled()) {
$user->setEnabled(false);
$this->userService->updateUser($user);
$io->success(sprintf('User "%s" has been deactivated.', $username));
} else {
$io->warning(sprintf('User "%s" is already deactivated.', $username));
}
return 0;
}
}

View File

@@ -0,0 +1,63 @@
<?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\Command;
use App\Entity\User;
use App\User\UserService;
use Symfony\Component\Console\Style\SymfonyStyle;
class DemoteUserCommand extends AbstractRoleCommand
{
/**
* {@inheritdoc}
*/
protected function configure()
{
parent::configure();
$this
->setName('kimai:user:demote')
->setAliases(['fos:user:demote'])
->setDescription('Demote a user by removing a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:demote</info> command demotes a user by removing a role
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
{
$username = $user->getUsername();
if ($super) {
if ($user->isSuperAdmin()) {
$user->setSuperAdmin(false);
$manipulator->updateUser($user);
$output->success(sprintf('Super administrator role has been removed from the user "%s".', $username));
} else {
$output->warning(sprintf('User "%s" doesn\'t have the super administrator role.', $username));
}
} else {
if ($user->hasRole($role)) {
$user->removeRole($role);
$manipulator->updateUser($user);
$output->success(sprintf('Role "%s" has been removed from user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" didn\'t have "%s" role.', $username, $role));
}
}
}
}

View File

@@ -136,7 +136,8 @@ final class KimaiImporterCommand extends Command
protected function configure()
{
$this
->setName('kimai:import-v1')
->setName('kimai:import:v1')
->setAliases(['kimai:import-v1'])
->setDescription('Import data from a Kimai v1 installation')
->setHelp('This command allows you to import the most important data from a Kimi v1 installation.')
->addArgument(

View File

@@ -0,0 +1,63 @@
<?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\Command;
use App\Entity\User;
use App\User\UserService;
use Symfony\Component\Console\Style\SymfonyStyle;
class PromoteUserCommand extends AbstractRoleCommand
{
/**
* {@inheritdoc}
*/
protected function configure()
{
parent::configure();
$this
->setName('kimai:user:promote')
->setAliases(['fos:user:promote'])
->setDescription('Promotes a user by adding a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:promote</info> command promotes a user by adding a role
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
<info>php %command.full_name% --super susan_super</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
{
$username = $user->getUsername();
if ($super) {
if (!$user->isSuperAdmin()) {
$user->setSuperAdmin(true);
$manipulator->updateUser($user);
$output->success(sprintf('User "%s" has been promoted as a super administrator.', $username));
} else {
$output->warning(sprintf('User "%s" does already have the super administrator role.', $username));
}
} else {
if (!$user->hasRole($role)) {
$user->addRole($role);
$manipulator->updateUser($user);
$output->success(sprintf('Role "%s" has been added to user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" did already have "%s" role.', $username, $role));
}
}
}
}

View File

@@ -110,13 +110,13 @@ class ResetTestCommand extends AbstractResetCommand
}
$user->setUsername($userConf[9]);
if ($userConf[10] !== null) {
$user->setUsernameCanonical($userConf[10]);
// removed field: UsernameCanonical
}
if ($userConf[11] !== null) {
$user->setEmail($userConf[11]);
}
if ($userConf[12] !== null) {
$user->setEmailCanonical($userConf[12]);
// removed field: EmailCanonical
}
if ($userConf[17] !== null) {
$user->setApiToken($userConf[17]);

View File

@@ -9,30 +9,32 @@
namespace App\Configuration;
class LdapConfiguration
final class LdapConfiguration
{
/**
* @var array
*/
protected $settings = [];
private $configuration;
public function __construct(array $settings)
public function __construct(SystemConfiguration $configuration)
{
$this->settings = $settings;
$this->configuration = $configuration;
}
public function isActivated(): bool
{
return $this->configuration->isLdapActive();
}
public function getRoleParameters(): array
{
return (array) $this->settings['role'];
return $this->configuration->getLdapRoleParameters();
}
public function getUserParameters(): array
{
return (array) $this->settings['user'];
return $this->configuration->getLdapUserParameters();
}
public function getConnectionParameters(): array
{
return (array) $this->settings['connection'];
return $this->configuration->getLdapConnectionParameters();
}
}

View File

@@ -0,0 +1,50 @@
<?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\Configuration;
final class SamlConfiguration
{
private $configuration;
public function __construct(SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
public function isActivated(): bool
{
return $this->configuration->isSamlActive();
}
public function getTitle(): string
{
return $this->configuration->getSamlTitle();
}
public function getAttributeMapping(): array
{
return $this->configuration->getSamlAttributeMapping();
}
public function getRolesAttribute(): ?string
{
return $this->configuration->getSamlRolesAttribute();
}
public function getRolesMapping(): array
{
return $this->configuration->getSamlRolesMapping();
}
public function getConnection(): array
{
return $this->configuration->getSamlConnection();
}
}

View File

@@ -23,6 +23,42 @@ class SystemConfiguration implements SystemBundleConfiguration
return $repository->getConfiguration();
}
// ========== Login form ==========
public function isLoginFormActive(): bool
{
if ($this->isLdapActive()) {
return true;
}
// if SAML is active, the login form can be deactivated
if (!$this->isSamlActive()) {
return true;
}
return (bool) $this->find('user.login');
}
public function isSelfRegistrationActive(): bool
{
return (bool) $this->find('user.registration');
}
public function getPasswordResetTokenLifetime(): int
{
return (int) $this->find('user.password_reset_token_ttl');
}
public function getPasswordResetRetryLifetime(): int
{
return (int) $this->find('user.password_reset_retry_ttl');
}
public function isPasswordResetActive(): bool
{
return (bool) $this->find('user.password_reset');
}
// ========== SAML configurations ==========
public function isSamlActive(): bool
@@ -30,6 +66,53 @@ class SystemConfiguration implements SystemBundleConfiguration
return (bool) $this->find('saml.activate');
}
public function getSamlTitle(): string
{
return (string) $this->find('saml.title');
}
public function getSamlAttributeMapping(): array
{
return (array) $this->find('saml.mapping');
}
public function getSamlRolesAttribute(): ?string
{
return (string) $this->find('saml.roles.attribute');
}
public function getSamlRolesMapping(): array
{
return (array) $this->find('saml.roles.mapping');
}
public function getSamlConnection(): array
{
return (array) $this->find('saml.connection');
}
// ========== LDAP configurations ==========
public function isLdapActive(): bool
{
return (bool) $this->find('ldap.activate');
}
public function getLdapRoleParameters(): array
{
return (array) $this->find('ldap.role');
}
public function getLdapUserParameters(): array
{
return (array) $this->find('ldap.user');
}
public function getLdapConnectionParameters(): array
{
return (array) $this->find('ldap.connection');
}
// ========== Calendar configurations ==========
public function getCalendarBusinessDays(): array
@@ -121,6 +204,7 @@ class SystemConfiguration implements SystemBundleConfiguration
return $this->find('defaults.user.language');
}
// TODO this is only used to display the hourly rate in the user profile
public function getUserDefaultCurrency(): string
{
return $this->find('defaults.user.currency');

View File

@@ -20,7 +20,6 @@ use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -35,18 +34,12 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*/
public const ROLE_ADMIN = User::ROLE_ADMIN;
/**
* @return DataCollectorTranslator
*/
private function getTranslator()
protected function getTranslator(): TranslatorInterface
{
return $this->container->get('translator');
}
/**
* @return LoggerInterface $logger
*/
private function getLogger()
private function getLogger(): LoggerInterface
{
return $this->container->get('logger');
}
@@ -57,7 +50,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
* @param string $translationKey
* @param array $parameter
*/
protected function flashSuccess($translationKey, $parameter = [])
protected function flashSuccess(string $translationKey, array $parameter = []): void
{
$this->addFlashTranslated('success', $translationKey, $parameter);
}
@@ -68,7 +61,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
* @param string $translationKey
* @param array $parameter
*/
protected function flashWarning($translationKey, $parameter = [])
protected function flashWarning(string $translationKey, array $parameter = []): void
{
$this->addFlashTranslated('warning', $translationKey, $parameter);
}
@@ -79,7 +72,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
* @param string $translationKey
* @param array $parameter
*/
protected function flashError($translationKey, $parameter = [])
protected function flashError(string $translationKey, array $parameter = []): void
{
$this->addFlashTranslated('error', $translationKey, $parameter);
}
@@ -89,7 +82,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*
* @param \Exception $exception
*/
protected function flashUpdateException(\Exception $exception)
protected function flashUpdateException(\Exception $exception): void
{
$this->flashException($exception, 'action.update.error');
}
@@ -99,7 +92,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*
* @param \Exception $exception
*/
protected function flashDeleteException(\Exception $exception)
protected function flashDeleteException(\Exception $exception): void
{
$this->flashException($exception, 'action.delete.error');
}
@@ -111,7 +104,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
* @param string $translationKey
* @param array $parameter
*/
protected function flashException(\Exception $exception, string $translationKey, array $parameter = [])
protected function flashException(\Exception $exception, string $translationKey, array $parameter = []): void
{
$this->logException($exception);
@@ -129,7 +122,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
* @param string $message
* @param array $parameter
*/
protected function addFlashTranslated(string $type, string $message, array $parameter = [])
protected function addFlashTranslated(string $type, string $message, array $parameter = []): void
{
if (!empty($parameter)) {
foreach ($parameter as $key => $value) {
@@ -145,7 +138,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$this->addFlash($type, $message);
}
protected function logException(\Exception $ex)
protected function logException(\Exception $ex): void
{
$this->getLogger()->critical($ex->getMessage());
}

View File

@@ -10,7 +10,7 @@
namespace App\Controller\Auth;
use App\Configuration\SystemConfiguration;
use App\Saml\SamlAuth;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -22,12 +22,12 @@ use Symfony\Component\Security\Core\Security;
*/
final class SamlController extends AbstractController
{
private $oneLoginAuth;
private $authFactory;
private $systemConfiguration;
public function __construct(SamlAuth $oneLoginAuth, SystemConfiguration $systemConfiguration)
public function __construct(SamlAuthFactory $authFactory, SystemConfiguration $systemConfiguration)
{
$this->oneLoginAuth = $oneLoginAuth;
$this->authFactory = $authFactory;
$this->systemConfiguration = $systemConfiguration;
}
@@ -59,7 +59,7 @@ final class SamlController extends AbstractController
throw new \RuntimeException($error);
}
$this->oneLoginAuth->login($session->get('_security.main.target_path'));
$this->authFactory->create()->login($session->get('_security.main.target_path'));
}
/**
@@ -71,7 +71,7 @@ final class SamlController extends AbstractController
throw $this->createNotFoundException('SAML deactivated');
}
$metadata = $this->oneLoginAuth->getSettings()->getSPMetadata();
$metadata = $this->authFactory->create()->getSettings()->getSPMetadata();
$response = new Response($metadata);
$response->headers->set('Content-Type', 'xml');

View File

@@ -18,15 +18,15 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\User\UserService;
use App\Utils\LocaleSettings;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* User profile controller
@@ -36,29 +36,10 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
*/
final class ProfileController extends AbstractController
{
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
/**
* @var TeamRepository
*/
private $teams;
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
}
/**
* @Route(path="/", name="my_profile", methods={"GET"})
*/
public function profileAction()
public function profileAction(): Response
{
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUsername()]);
}
@@ -67,7 +48,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}", name="user_profile", methods={"GET"})
* @Security("is_granted('view', profile)")
*/
public function indexAction(User $profile, TimesheetRepository $repository, LocaleSettings $localeSettings)
public function indexAction(User $profile, TimesheetRepository $repository, LocaleSettings $localeSettings): Response
{
$userStats = $repository->getUserStatistics($profile);
@@ -91,7 +72,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', profile)")
*/
public function editAction(User $profile, Request $request)
public function editAction(User $profile, Request $request): Response
{
$form = $this->createEditForm($profile);
$form->handleRequest($request);
@@ -117,18 +98,13 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/password", name="user_profile_password", methods={"GET", "POST"})
* @Security("is_granted('password', profile)")
*/
public function passwordAction(User $profile, Request $request)
public function passwordAction(User $profile, Request $request, UserService $userService): Response
{
$form = $this->createPasswordForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $this->encoder->encodePassword($profile, $profile->getPlainPassword());
$profile->setPassword($password);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userService->updateUser($profile);
$this->flashSuccess('action.update.success');
@@ -146,18 +122,13 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/api-token", name="user_profile_api_token", methods={"GET", "POST"})
* @Security("is_granted('api-token', profile)")
*/
public function apiTokenAction(User $profile, Request $request)
public function apiTokenAction(User $profile, Request $request, UserService $userService): Response
{
$form = $this->createApiTokenForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $this->encoder->encodePassword($profile, $profile->getPlainApiToken());
$profile->setApiToken($password);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userService->updateUser($profile);
$this->flashSuccess('action.update.success');
@@ -175,7 +146,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
* @Security("is_granted('roles', profile)")
*/
public function rolesAction(User $profile, Request $request)
public function rolesAction(User $profile, Request $request): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@@ -209,7 +180,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
public function teamsAction(User $profile, Request $request)
public function teamsAction(User $profile, Request $request): Response
{
$form = $this->createTeamsForm($profile);
$form->handleRequest($request);
@@ -235,11 +206,11 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)")
*/
public function preferencesAction(User $profile, Request $request)
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch($event);
$dispatcher->dispatch($event);
$original = [];
foreach ($profile->getPreferences() as $preference) {

View File

@@ -0,0 +1,195 @@
<?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\Controller\Security;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent;
use App\Form\PasswordResetForm;
use App\User\LoginManager;
use App\User\UserService;
use DateTime;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @Route(path="/resetting")
*/
final class PasswordResetController extends AbstractController
{
private $eventDispatcher;
private $userService;
private $configuration;
public function __construct(EventDispatcherInterface $eventDispatcher, UserService $userService, SystemConfiguration $configuration)
{
$this->eventDispatcher = $eventDispatcher;
$this->userService = $userService;
$this->configuration = $configuration;
}
/**
* Request reset user password: show form.
*
* @Route(path="/request", name="fos_user_resetting_request", methods={"GET"})
*/
public function requestAction(): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
return $this->render('security/password-reset/request.html.twig');
}
/**
* Request reset user password: submit form and send email.
*
* @Route(path="/send-email", name="fos_user_resetting_send_email", methods={"POST"})
*/
public function sendEmailAction(Request $request): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$username = $request->request->get('username');
$user = $this->userService->findUserByUsernameOrEmail($username);
if (null !== $user && !$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
if (!$user->isInternalUser()) {
throw $this->createAccessDeniedException(
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUsername(), $user->getAuth())
);
}
if (null === $user->getConfirmationToken()) {
$user->setConfirmationToken($this->userService->generateSecurityToken());
}
$mail = $this->generateResettingEmailMessage($user);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
// this will finally send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$user->setPasswordRequestedAt(new DateTime());
$this->userService->updateUser($user);
}
return $this->redirectToRoute('fos_user_resetting_check_email', ['username' => $username]);
}
/**
* Tell the user to check his email provider.
*
* @Route(path="/check-email", name="fos_user_resetting_check_email", methods={"GET"})
*/
public function checkEmailAction(Request $request): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$username = $request->query->get('username');
if (empty($username)) {
// the user does not come from the sendEmail action
return $this->redirectToRoute('fos_user_resetting_request');
}
return $this->render('security/password-reset/check_email.html.twig', [
'tokenLifetime' => ceil($this->configuration->getPasswordResetRetryLifetime() / 3600),
]);
}
/**
* Reset user password.
*
* @Route(path="/reset/{token}", name="fos_user_resetting_reset", methods={"GET", "POST"})
*/
public function resetAction(Request $request, LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
}
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetTokenLifetime())) {
return $this->redirectToRoute('fos_user_resetting_request');
}
$form = $this->createResetForm();
$form->setData($user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setConfirmationToken(null);
$user->setPasswordRequestedAt(null);
$user->setEnabled(true);
$this->userService->updateUser($user);
$response = $this->redirectToRoute('my_profile');
$loginManager->logInUser($user, $response);
return $response;
}
return $this->render('security/password-reset/reset.html.twig', [
'token' => $token,
'form' => $form->createView(),
]);
}
private function createResetForm(): FormInterface
{
$options = ['validation_groups' => ['ResetPassword', 'Default']];
return $this->createFormBuilder()->create('fos_user_resetting_form', PasswordResetForm::class, $options)->getForm();
}
private function generateResettingEmailMessage(User $user): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('fos_user_resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->getTranslator()->trans('reset.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/password-reset.html.twig')
->context([
'user' => $user,
'username' => $username,
'confirmationUrl' => $url,
])
;
}
}

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\Controller\Security;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class SecurityController extends AbstractController
{
private $tokenManager;
public function __construct(CsrfTokenManagerInterface $tokenManager)
{
$this->tokenManager = $tokenManager;
}
/**
* @Route(path="/login", name="fos_user_security_login", methods={"GET", "POST"})
*/
public function loginAction(Request $request): Response
{
/** @var SessionInterface $session */
$session = $request->getSession();
$authErrorKey = Security::AUTHENTICATION_ERROR;
$lastUsernameKey = Security::LAST_USERNAME;
// get the error if any (works with forward and redirect -- see below)
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 instanceof AuthenticationException) {
$error = null; // The value does not come from the security component.
}
$lastUsername = '';
if ($request->hasSession()) {
$lastUsername = $session->get($lastUsernameKey);
}
$csrfToken = $this->tokenManager->getToken('authenticate')->getValue();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
'csrf_token' => $csrfToken,
]);
}
/**
* @Route(path="/login_check", name="fos_user_security_check", methods={"POST"})
*/
public function checkAction()
{
throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.');
}
/**
* @Route(path="/logout", name="fos_user_security_logout", methods={"GET", "POST"})
*/
public function logoutAction()
{
throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
}
}

View File

@@ -0,0 +1,212 @@
<?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\Controller\Security;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailSelfRegistrationEvent;
use App\Form\SelfRegistrationForm;
use App\User\LoginManager;
use App\User\UserService;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @Route(path="/register")
*/
class SelfRegistrationController extends AbstractController
{
private $eventDispatcher;
private $userService;
private $tokenStorage;
private $configuration;
public function __construct(EventDispatcherInterface $eventDispatcher, UserService $userService, TokenStorageInterface $tokenStorage, SystemConfiguration $configuration)
{
$this->eventDispatcher = $eventDispatcher;
$this->userService = $userService;
$this->tokenStorage = $tokenStorage;
$this->configuration = $configuration;
}
/**
* @Route(path="/", name="fos_user_registration_register", methods={"GET", "POST"})
*/
public function registerAction(Request $request): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$user = $this->userService->createNewUser();
$user->setLanguage($request->getLocale());
$form = $this->createSelfRegistrationForm();
$form->setData($user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setEnabled(false);
$user->setConfirmationToken($this->userService->generateSecurityToken());
$mail = $this->generateConfirmationEmail($user);
$event = new EmailSelfRegistrationEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
// this will finally send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$request->getSession()->set('fos_user_send_confirmation_email/email', $user->getEmail());
$this->userService->saveNewUser($user);
return $this->redirectToRoute('fos_user_registration_check_email');
}
return $this->render('security/self-registration/register.html.twig', [
'form' => $form->createView(),
]);
}
/**
* Tell the user to check their email provider.
*
* @Route(path="/check-email", name="fos_user_registration_check_email", methods={"GET"})
*/
public function checkEmailAction(Request $request): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$email = $request->getSession()->get('fos_user_send_confirmation_email/email');
if (empty($email)) {
return $this->redirectToRoute('fos_user_registration_register');
}
$request->getSession()->remove('fos_user_send_confirmation_email/email');
$user = $this->userService->findUserByEmail($email);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
}
return $this->render('security/self-registration/check_email.html.twig', [
'user' => $user,
]);
}
/**
* Receive the confirmation token from user email provider, login the user.
*
* @Route(path="/confirm/{token}", name="fos_user_registration_confirm", methods={"GET"})
*/
public function confirmAction(LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('fos_user_security_login');
}
$user->setConfirmationToken(null);
$user->setEnabled(true);
$this->userService->updateUser($user);
$response = $this->redirectToRoute('fos_user_registration_confirmed');
$loginManager->logInUser($user, $response);
return $response;
}
/**
* Tell the user his account is now confirmed.
*
* @Route(path="/confirmed", name="fos_user_registration_confirmed", methods={"GET"})
*/
public function confirmedAction(Request $request): Response
{
if (!$this->configuration->isSelfRegistrationActive()) {
throw $this->createNotFoundException();
}
$user = $this->getUser();
if ($user === null) {
throw $this->createAccessDeniedException('This user does not have access to this section.');
}
return $this->render('security/self-registration/confirmed.html.twig', [
'user' => $user,
'targetUrl' => $this->getTargetUrlFromSession($request->getSession()),
]);
}
private function createSelfRegistrationForm(): FormInterface
{
$options = ['validation_groups' => ['Registration', 'Default']];
return $this->createFormBuilder()->create('fos_user_registration_form', SelfRegistrationForm::class, $options)->getForm();
}
private function getTargetUrlFromSession(SessionInterface $session): ?string
{
$token = $this->tokenStorage->getToken();
if (!method_exists($token, 'getProviderKey')) {
return null;
}
$key = sprintf('_security.%s.target_path', $token->getProviderKey());
if ($session->has($key)) {
return $session->get($key);
}
return null;
}
private function generateConfirmationEmail(User $user): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('fos_user_registration_confirm', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->getTranslator()->trans('registration.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/confirmation.html.twig')
->context([
'user' => $user,
'username' => $username,
'confirmationUrl' => $url,
])
;
}
}

View File

@@ -265,7 +265,60 @@ final class SystemConfigurationController extends AbstractController
}
}
return [
$authentication = (new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_AUTHENTICATION)
->setConfiguration([
(new Configuration())
->setName('user.login')
->setLabel('user_auth_login')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('user.registration')
->setLabel('user_auth_registration')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('user.password_reset')
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset')
->setType(YesNoType::class),
(new Configuration())
->setName('user.password_reset_retry_ttl')
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset_retry_ttl')
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
->setType(IntegerType::class),
(new Configuration())
->setName('user.password_reset_token_ttl')
->setTranslationDomain('system-configuration')
->setLabel('user_auth_password_reset_token_ttl')
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
->setType(IntegerType::class),
/*
(new Configuration())
->setName('ldap.activate')
->setLabel('ldap_activate')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
(new Configuration())
->setName('saml.activate')
->setLabel('saml_activate')
->setTranslationDomain('system-configuration')
->setType(YesNoType::class),
*/
]);
if (!$this->configurations->isSamlActive()) {
$authentication->getConfigurationByName('user.login')->setEnabled(false);
}
if (!$this->configurations->isPasswordResetActive()) {
$authentication->getConfigurationByName('user.password_reset_retry_ttl')->setEnabled(false);
$authentication->getConfigurationByName('user.password_reset_token_ttl')->setEnabled(false);
}
$configurationModels = [
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_TIMESHEET)
->setConfiguration([
@@ -399,6 +452,7 @@ final class SystemConfigurationController extends AbstractController
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
$authentication,
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
->setConfiguration([
@@ -539,5 +593,7 @@ final class SystemConfigurationController extends AbstractController
->setOptions(['input' => 'string']),
]),
];
return $configurationModels;
}
}

View File

@@ -70,14 +70,24 @@ 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']); // @deprecated since 1.13
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
$this->setLdapParameter($config['ldap'], $container);
if (!isset($config['ldap']['connection']['baseDn'])) {
$config['ldap']['connection']['baseDn'] = $config['ldap']['user']['baseDn'];
}
if (empty($config['ldap']['connection']['accountFilterFormat']) && $config['ldap']['connection']['bindRequiresDn']) {
$filter = '';
if (!empty($config['ldap']['user']['filter'])) {
$filter = $config['ldap']['user']['filter'];
}
$config['ldap']['connection']['accountFilterFormat'] = '(&' . $filter . '(' . $config['ldap']['user']['usernameAttribute'] . '=%s))';
}
// @deprecated since 1.15
$container->setParameter('kimai.ldap', $config['ldap']);
// translation files, which can overwrite the default kimai translations
$localTranslations = [];
@@ -129,23 +139,6 @@ class AppExtension extends Extension
$container->setParameter('kimai.languages', $config);
}
protected function setLdapParameter(array $config, ContainerBuilder $container)
{
if (!isset($config['connection']['baseDn'])) {
$config['connection']['baseDn'] = $config['user']['baseDn'];
}
if (empty($config['connection']['accountFilterFormat']) && $config['connection']['bindRequiresDn']) {
$filter = '';
if (!empty($config['user']['filter'])) {
$filter = $config['user']['filter'];
}
$config['connection']['accountFilterFormat'] = '(&' . $filter . '(' . $config['user']['usernameAttribute'] . '=%s))';
}
$container->setParameter('kimai.ldap', $config);
}
/**
* Performs some pre-compilation on the configured permissions from kimai.yaml
* to save us from constant array lookups from during runtime.
@@ -229,27 +222,6 @@ class AppExtension extends Extension
$container->setParameter('kimai.theme.show_about', $config['show_about']);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
protected function createUserParameter(array $config, ContainerBuilder $container)
{
if (!$config['registration']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_registration'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
if (!$config['password_reset']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_password_reset'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
$container->setParameter('kimai.fosuser', $config);
}
/**
* @return string
*/

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection\Compiler;
use App\Configuration\SystemConfiguration;
use App\Configuration\ThemeConfiguration;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -29,8 +30,8 @@ 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]);
$config = $container->getDefinition(SystemConfiguration::class);
$twig->addMethodCall('addGlobal', ['kimai_config', $config]);
$definition = $container->getDefinition('twig.loader.native_filesystem');

View File

@@ -512,12 +512,21 @@ class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
->booleanNode('registration')
->booleanNode('login')
->defaultTrue()
->end()
->booleanNode('registration')
->defaultFalse()
->end()
->booleanNode('password_reset')
->defaultTrue()
->end()
->integerNode('password_reset_retry_ttl')
->defaultValue(7200)
->end()
->integerNode('password_reset_token_ttl')
->defaultValue(86400)
->end()
->end()
;
@@ -670,6 +679,9 @@ class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
->booleanNode('activate')
->defaultFalse()
->end()
->arrayNode('connection')
->addDefaultsIfNotSet()
->children()
@@ -767,13 +779,13 @@ class Configuration implements ConfigurationInterface
->end()
->validate()
->ifTrue(static function ($v) {
return null !== $v['connection']['host'] && !\extension_loaded('ldap');
return $v['activate'] && !\extension_loaded('ldap');
})
->thenInvalid('LDAP is activated, but the LDAP PHP extension is not loaded.')
->end()
->validate()
->ifTrue(static function ($v) {
return null !== $v['connection']['host'] && empty($v['user']['baseDn']);
return $v['activate'] && empty($v['user']['baseDn']);
})
->thenInvalid('The "ldap.user.baseDn" config must be set if LDAP is activated.')
->end()

View File

@@ -12,14 +12,15 @@ namespace App\Entity;
use App\Constants;
use App\Export\Annotation as Exporter;
use App\Utils\StringHelper;
use App\Validator\Constraints as Constraints;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use FOS\UserBundle\Model\User as BaseUser;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
@@ -33,6 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* )
* @UniqueEntity("username")
* @UniqueEntity("email")
* @Constraints\User(groups={"UserCreate", "Registration", "Default"})
*
* @Serializer\ExclusionPolicy("all")
* @Serializer\VirtualProperty(
@@ -64,12 +66,12 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
* @Exporter\Expose("active", label="label.active", exp="object.isEnabled()", type="boolean")
*/
class User extends BaseUser implements UserInterface
class User implements UserInterface, EquatableInterface, \Serializable
{
public const ROLE_USER = 'ROLE_USER';
public const ROLE_TEAMLEAD = 'ROLE_TEAMLEAD';
public const ROLE_ADMIN = 'ROLE_ADMIN';
//public const ROLE_SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
public const ROLE_SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
public const DEFAULT_ROLE = self::ROLE_USER;
public const DEFAULT_LANGUAGE = Constants::DEFAULT_LOCALE;
@@ -156,6 +158,8 @@ class User extends BaseUser implements UserInterface
/**
* @var string
* @internal to be set via form, must not be persisted
* @Assert\NotBlank(groups={"ApiTokenUpdate"})
* @Assert\Length(min="8", max="60", groups={"ApiTokenUpdate"})
*/
private $plainApiToken;
/**
@@ -205,10 +209,79 @@ class User extends BaseUser implements UserInterface
* @internal has no database mapping as the value is calculated from a permission
*/
private $isAllowedToSeeAllData = null;
/**
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @var string
* @ORM\Column(name="username", type="string", length=180)
* @Assert\NotBlank(groups={"Registration", "UserCreate", "Profile"})
* @Assert\Length(min="2", max="60", groups={"Registration", "UserCreate", "Profile"})
*/
private $username;
/**
* @var string
* @ORM\Column(name="email", type="string", length=180)
* @Assert\NotBlank(groups={"Registration", "UserCreate", "Profile"})
* @Assert\Length(min="2", max="180")
* @Assert\Email(groups={"Registration", "UserCreate", "Profile"})
*/
private $email;
/**
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @var bool
* @ORM\Column(name="enabled", type="boolean")
*/
private $enabled = false;
/**
* Encrypted password. Must be persisted.
*
* @var string
* @ORM\Column(name="password", type="string")
*/
private $password;
/**
* Plain password. Used for model validation, not persisted.
*
* TODO make the password rules configurable
*
* @var string|null
* @Assert\NotBlank(groups={"Registration", "PasswordUpdate", "UserCreate"})
* @Assert\Length(min="8", max="60", groups={"Registration", "PasswordUpdate", "UserCreate", "ResetPassword", "ChangePassword"})
*/
private $plainPassword;
/**
* @var \DateTime|null
* @ORM\Column(name="last_login", type="datetime", nullable=true)
*/
private $lastLogin;
/**
* Random string sent to the user email address in order to verify it.
*
* @var string|null
* @ORM\Column(name="confirmation_token", type="string", length=180, unique=true, nullable=true)
*/
private $confirmationToken;
/**
* @var \DateTime|null
* @ORM\Column(name="password_requested_at", type="datetime", nullable=true)
*/
private $passwordRequestedAt;
/**
* @Serializer\Expose()
* @Serializer\Groups({"User_Entity"})
* @Serializer\Type("array<string>")
*
* @var array
* @ORM\Column(name="roles", type="array")
* @Constraints\Role(groups={"RolesUpdate"})
*/
private $roles = [];
public function __construct()
{
parent::__construct();
$this->registeredAt = new DateTime();
$this->preferences = new ArrayCollection();
$this->teams = new ArrayCollection();
@@ -557,6 +630,265 @@ class User extends BaseUser implements UserInterface
return $this->auth === null || $this->auth === self::AUTH_INTERNAL;
}
/**
* {@inheritdoc}
*/
public function addRole($role)
{
$role = strtoupper($role);
if ($role === static::DEFAULT_ROLE) {
return $this;
}
if (!\in_array($role, $this->roles, true)) {
$this->roles[] = $role;
}
return $this;
}
/**
* {@inheritdoc}
*/
public function eraseCredentials()
{
$this->plainPassword = null;
}
/**
* {@inheritdoc}
*/
public function getUsername()
{
return $this->username;
}
/**
* {@inheritdoc}
*/
public function getEmail()
{
return $this->email;
}
/**
* {@inheritdoc}
*/
public function getPassword()
{
return $this->password;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function getLastLogin(): ?DateTime
{
return $this->lastLogin;
}
public function getConfirmationToken(): ?string
{
return $this->confirmationToken;
}
/**
* {@inheritdoc}
*/
public function getRoles()
{
$roles = $this->roles;
// we need to make sure to have at least one role
$roles[] = static::DEFAULT_ROLE;
return array_values(array_unique($roles));
}
public function hasRole($role): bool
{
return \in_array(strtoupper($role), $this->getRoles(), true);
}
public function setSuperAdmin(bool $isSuper): void
{
if (true === $isSuper) {
$this->addRole(static::ROLE_SUPER_ADMIN);
} else {
$this->removeRole(static::ROLE_SUPER_ADMIN);
}
}
public function isSuperAdmin(): bool
{
return $this->hasRole(static::ROLE_SUPER_ADMIN);
}
public function removeRole($role): User
{
if (false !== $key = array_search(strtoupper($role), $this->roles, true)) {
unset($this->roles[$key]);
$this->roles = array_values($this->roles);
}
return $this;
}
public function setUsername($username): User
{
$this->username = $username;
return $this;
}
public function setEmail($email): User
{
$this->email = $email;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): User
{
$this->enabled = $enabled;
return $this;
}
public function setPassword($password): User
{
$this->password = $password;
return $this;
}
public function setPlainPassword($password): User
{
$this->plainPassword = $password;
return $this;
}
public function setLastLogin(\DateTime $time = null): User
{
$this->lastLogin = $time;
return $this;
}
public function setConfirmationToken($confirmationToken): User
{
$this->confirmationToken = $confirmationToken;
return $this;
}
public function setPasswordRequestedAt(\DateTime $date = null): User
{
$this->passwordRequestedAt = $date;
return $this;
}
/**
* Gets the timestamp that the user requested a password reset.
*
* @return DateTime|null
*/
public function getPasswordRequestedAt(): ?DateTime
{
return $this->passwordRequestedAt;
}
public function isPasswordRequestNonExpired(int $seconds): bool
{
$date = $this->getPasswordRequestedAt();
if ($date === null || !($date instanceof DateTime)) {
return false;
}
return $date->getTimestamp() + $seconds > time();
}
public function setRoles(array $roles): User
{
$this->roles = [];
foreach ($roles as $role) {
$this->addRole($role);
}
return $this;
}
public function isEqualTo(UserInterface $user)
{
if (!$user instanceof self) {
return false;
}
if ($this->password !== $user->getPassword()) {
return false;
}
if ($this->username !== $user->getUsername()) {
return false;
}
return true;
}
/**
* {@inheritdoc}
*/
public function serialize()
{
return serialize([
$this->password,
$this->username,
$this->enabled,
$this->id,
$this->email,
]);
}
/**
* {@inheritdoc}
*/
public function unserialize($serialized)
{
$data = unserialize($serialized);
// unserialize a user object from <= 1.14
if (8 === \count($data)) {
unset($data[1], $data[2], $data[7]);
$data = array_values($data);
}
list(
$this->password,
$this->username,
$this->enabled,
$this->id,
$this->email) = $data;
}
/**
* {@inheritdoc}
*/
public function getSalt()
{
return null;
}
/**
* @return string
*/

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base event class to used with user manipulations.
*/
abstract class AbstractUserEvent extends Event
{
/**
* @var User
*/
private $user;
public function __construct(User $user)
{
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
}

28
src/Event/EmailEvent.php Normal file
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\Event;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\EventDispatcher\Event;
class EmailEvent extends Event
{
private $email;
public function __construct(Email $email)
{
$this->email = $email;
}
public function getEmail(): Email
{
return $this->email;
}
}

View File

@@ -0,0 +1,14 @@
<?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\Event;
final class EmailPasswordResetEvent extends UserEmailEvent
{
}

View File

@@ -0,0 +1,14 @@
<?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\Event;
final class EmailSelfRegistrationEvent extends UserEmailEvent
{
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for new user instances, which might or might not be saved.
*/
final class UserCreateEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for new user instances, which were just saved.
*/
final class UserCreatePostEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for new user instances, which are just about to being saved.
*/
final class UserCreatePreEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,29 @@
<?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\Event;
use App\Entity\User;
use Symfony\Component\Mime\Email;
class UserEmailEvent extends EmailEvent
{
private $user;
public function __construct(User $user, Email $email)
{
parent::__construct($email);
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for programmatic logins (like password reset or registration).
*/
final class UserInteractiveLoginEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for user instances, which were just updated.
*/
final class UserUpdatePostEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,17 @@
<?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\Event;
/**
* Triggered for user instances, which are about to be updated.
*/
final class UserUpdatePreEvent extends AbstractUserEvent
{
}

View File

@@ -0,0 +1,39 @@
<?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\Event\EmailEvent;
use App\Mail\KimaiMailer;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Event subscriber to handle emails.
*/
final class EmailSubscriber implements EventSubscriberInterface
{
private $mailer;
public function __construct(KimaiMailer $mailer)
{
$this->mailer = $mailer;
}
public static function getSubscribedEvents(): array
{
return [
EmailEvent::class => ['onMailEvent', 100],
];
}
public function onMailEvent(EmailEvent $event)
{
$this->mailer->send($event->getEmail());
}
}

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\EventSubscriber;
use App\Entity\User;
use App\Event\UserInteractiveLoginEvent;
use App\Repository\UserRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
class LastLoginSubscriber implements EventSubscriberInterface
{
private $repository;
public function __construct(UserRepository $repository)
{
$this->repository = $repository;
}
/**
* @return array
*/
public static function getSubscribedEvents()
{
return [
UserInteractiveLoginEvent::class => 'onImplicitLogin',
SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
];
}
public function onImplicitLogin(UserInteractiveLoginEvent $event)
{
$user = $event->getUser();
$user->setLastLogin(new \DateTime());
$this->repository->saveUser($user);
}
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
{
$user = $event->getAuthenticationToken()->getUser();
if ($user instanceof User) {
$user->setLastLogin(new \DateTime());
$this->repository->saveUser($user);
}
}
}

View File

@@ -1,78 +0,0 @@
<?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\FormEvent;
use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* This class intercepts the registration to make sure:
*
* - the first-ever registered user will get the SUPER_ADMIN role
* - the user uses the current request locale as initial language setting
*/
final class RegistrationSubscriber implements EventSubscriberInterface
{
/**
* @var UserManagerInterface
*/
private $userManager;
/**
* @var UrlGeneratorInterface
*/
private $router;
public function __construct(UserManagerInterface $userManager, UrlGeneratorInterface $router)
{
$this->userManager = $userManager;
$this->router = $router;
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
FOSUserEvents::REGISTRATION_SUCCESS => ['onRegistrationSuccess', 200],
FOSUserEvents::RESETTING_RESET_SUCCESS => ['onResettingSuccess', 200],
];
}
/**
* @param FormEvent $event
*/
public function onRegistrationSuccess(FormEvent $event)
{
/** @var User $user */
$user = $event->getForm()->getData();
$roles = [User::ROLE_USER];
if (empty($this->userManager->findUsers())) {
$roles = [User::ROLE_SUPER_ADMIN];
}
$user->setLanguage($event->getRequest()->getLocale());
$user->setRoles($roles);
}
/**
* @param FormEvent $event
*/
public function onResettingSuccess(FormEvent $event)
{
$event->setResponse(new RedirectResponse($this->router->generate('my_profile')));
}
}

View File

@@ -1,44 +0,0 @@
<?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

@@ -50,7 +50,7 @@ final class Configuration
*/
private $constraints = [];
public function getName(): string
public function getName(): ?string
{
return $this->name;
}

View File

@@ -18,6 +18,7 @@ class SystemConfiguration
public const SECTION_FORM_CUSTOMER = 'form_customer';
public const SECTION_FORM_USER = 'form_user';
public const SECTION_THEME = 'theme';
public const SECTION_AUTHENTICATION = 'authentication';
public const SECTION_CALENDAR = 'calendar';
public const SECTION_BRANDING = 'branding';
@@ -50,6 +51,17 @@ class SystemConfiguration
return $this->configuration;
}
public function getConfigurationByName(string $name): ?Configuration
{
foreach ($this->configuration as $configuration) {
if ($configuration->getName() === $name) {
return $configuration;
}
}
return null;
}
/**
* @param Configuration[] $configuration
* @return SystemConfiguration

View File

@@ -0,0 +1,57 @@
<?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\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PasswordResetForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => ['label' => 'label.password'],
'second_options' => ['label' => 'label.password_repeat'],
'invalid_message' => 'The entered passwords don\'t match.',
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_token_id' => 'resetting',
]);
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'password_resetting';
}
}

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\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SelfRegistrationForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class, ['label' => 'label.email'])
->add('username', null, ['label' => 'Username', 'translation_domain' => 'AdminLTEBundle'])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => ['label' => 'label.password'],
'second_options' => ['label' => 'label.password_repeat'],
'invalid_message' => 'The entered passwords don\'t match.',
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_token_id' => 'registration',
]);
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'user_registration';
}
}

View File

@@ -205,9 +205,6 @@ class Kernel extends BaseKernel
{
$confDir = $this->getProjectDir() . '/config';
// some routes are based on app configs and will be imported manually
$this->configureFosUserRoutes($routes);
// load bundle specific route files
if (is_dir($confDir . '/routes/')) {
$routes->import($confDir . '/routes/*' . self::CONFIG_EXTS, '/', 'glob');
@@ -227,25 +224,4 @@ class Kernel extends BaseKernel
}
}
}
protected function configureFosUserRoutes(RouteCollectionBuilder $routes)
{
$features = $this->getContainer()->getParameter('kimai.fosuser');
// Expose the user registration feature
if ($features['registration']) {
$routes->import(
'@FOSUserBundle/Resources/config/routing/registration.xml',
'/{_locale}/register'
);
}
// Expose the users password-reset feature
if ($features['password_reset']) {
$routes->import(
'@FOSUserBundle/Resources/config/routing/resetting.xml',
'/{_locale}/resetting'
);
}
}
}

View File

@@ -20,12 +20,12 @@ use Symfony\Component\DependencyInjection\Reference;
*/
class FormLoginLdapFactory implements SecurityFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPoint)
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId)
{
$authProviderId = $this->createAuthProvider($container, $id, $userProviderId);
$listenerId = $this->createListener($container, $id, $config);
return [$authProviderId, $listenerId, $defaultEntryPoint];
return [$authProviderId, $listenerId, $defaultEntryPointId];
}
public function getPosition()
@@ -38,7 +38,7 @@ class FormLoginLdapFactory implements SecurityFactoryInterface
return 'kimai_ldap';
}
public function addConfiguration(NodeDefinition $node)
public function addConfiguration(NodeDefinition $builder)
{
}

View File

@@ -12,6 +12,7 @@ namespace App\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Provider\UserAuthenticationProvider;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
@@ -48,6 +49,15 @@ class LdapAuthenticationProvider extends UserAuthenticationProvider
$this->userProvider = $userProvider;
}
public function supports(TokenInterface $token)
{
if (!$this->config->isActivated()) {
return false;
}
return parent::supports($token);
}
protected function retrieveUser($username, UsernamePasswordToken $token)
{
$user = $token->getUser();
@@ -56,7 +66,7 @@ class LdapAuthenticationProvider extends UserAuthenticationProvider
}
try {
// this will always query the FOSUserBundle first...
// this will always query the internal database first...
// only first-time logins from LDAP user (not yet existing in local user database)
// will actually hit the LdapUserProvider
$user = $this->userProvider->loadUserByUsername($username);

View File

@@ -15,29 +15,17 @@ use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*
* @final
*/
class LdapManager
{
/**
* @var LdapConfiguration
*/
protected $config;
/**
* @var LdapDriver
*/
protected $driver;
/**
* @var array
*/
protected $params = [];
/**
* @var LdapUserHydrator
*/
protected $hydrator;
private $driver;
private $hydrator;
private $config;
public function __construct(LdapDriver $driver, LdapUserHydrator $hydrator, LdapConfiguration $config)
{
$this->params = $config->getUserParameters();
$this->config = $config;
$this->driver = $driver;
$this->hydrator = $hydrator;
@@ -52,7 +40,9 @@ class LdapManager
*/
public function findUserByUsername(string $username): ?UserInterface
{
return $this->findUserBy([$this->params['usernameAttribute'] => $username]);
$params = $this->config->getUserParameters();
return $this->findUserBy([$params['usernameAttribute'] => $username]);
}
/**
@@ -62,8 +52,9 @@ class LdapManager
*/
public function findUserBy(array $criteria): ?UserInterface
{
$params = $this->config->getUserParameters();
$filter = $this->buildFilter($criteria);
$entries = $this->driver->search($this->params['baseDn'], $filter);
$entries = $this->driver->search($params['baseDn'], $filter);
if ($entries['count'] > 1) {
throw new LdapDriverException('This search must only return a single user');
@@ -77,10 +68,12 @@ class LdapManager
return $this->hydrator->hydrate($entries[0]);
}
protected function buildFilter(array $criteria, string $condition = '&'): string
private function buildFilter(array $criteria, string $condition = '&'): string
{
$params = $this->config->getUserParameters();
$filters = [];
$filters[] = $this->params['filter'];
$filters[] = $params['filter'];
foreach ($criteria as $key => $value) {
$value = ldap_escape($value, '', LDAP_ESCAPE_FILTER);
$filters[] = sprintf('(%s=%s)', $key, $value);
@@ -118,7 +111,8 @@ class LdapManager
}
$user->setPreferenceValue('ldap.dn', $baseDn);
$entries = $this->driver->search($baseDn, $this->params['attributesFilter']);
$params = $this->config->getUserParameters();
$entries = $this->driver->search($baseDn, $params['attributesFilter']);
if ($entries['count'] > 1) {
throw new LdapDriverException('This search must only return a single user');
@@ -151,7 +145,7 @@ class LdapManager
}
}
protected function getRoles(string $dn, array $roleParameter): array
private function getRoles(string $dn, array $roleParameter): array
{
$filter = $roleParameter['filter'] ?? '';

View File

@@ -20,21 +20,13 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
* Overwritten to be able to deactivate LDAP via config switch.
*
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*
* @final
*/
class LdapUserProvider implements UserProviderInterface
{
/**
* @var bool
*/
protected $activated = false;
/**
* @var LdapManager
*/
protected $ldapManager;
/**
* @var LoggerInterface|null
*/
protected $logger;
private $ldapManager;
private $logger;
public function __construct(LdapManager $ldapManager, LoggerInterface $logger = null)
{

View File

@@ -1,97 +0,0 @@
<?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\Mail;
use App\Entity\User;
use FOS\UserBundle\Mailer\MailerInterface as FOSMailerInterface;
use FOS\UserBundle\Model\UserInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class UserMails implements FOSMailerInterface
{
/**
* @var KimaiMailer
*/
private $mailer;
/**
* @var UrlGeneratorInterface
*/
private $router;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(KimaiMailer $mailer, UrlGeneratorInterface $router, TranslatorInterface $translator)
{
$this->mailer = $mailer;
$this->router = $router;
$this->translator = $translator;
}
public function sendConfirmationEmailMessage(UserInterface $user)
{
$username = $user->getUsername();
$language = User::DEFAULT_LANGUAGE;
if ($user instanceof User) {
$username = $user->getDisplayName();
$language = $user->getLanguage();
}
$url = $this->router->generate('fos_user_registration_confirm', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
$email = (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->translator->trans('registration.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/confirmation.html.twig')
->context([
'user' => $user,
'username' => $username,
'confirmationUrl' => $url,
])
;
$this->mailer->send($email);
}
public function sendResettingEmailMessage(UserInterface $user)
{
$username = $user->getUsername();
$language = User::DEFAULT_LANGUAGE;
if ($user instanceof User) {
$username = $user->getDisplayName();
$language = $user->getLanguage();
}
$url = $this->router->generate('fos_user_resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
$email = (new TemplatedEmail())
->to(new Address($user->getEmail()))
->subject(
$this->translator->trans('reset.subject', ['%username%' => $username], 'email', $language)
)
->htmlTemplate('emails/password-reset.html.twig')
->context([
'user' => $user,
'username' => $username,
'confirmationUrl' => $url,
])
;
$this->mailer->send($email);
}
}

View File

@@ -0,0 +1,43 @@
<?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 Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* @version 1.15
*/
final class Version20210605154245 extends AbstractMigration
{
public function getDescription(): string
{
return 'Cleans up User table';
}
public function up(Schema $schema): void
{
$user = $schema->getTable('kimai2_users');
$user->dropIndex('UNIQ_B9AC5BCE92FC23A8');
$user->dropIndex('UNIQ_B9AC5BCEA0D96FBF');
$user->dropColumn('username_canonical');
$user->dropColumn('email_canonical');
$user->dropColumn('salt');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_users ADD username_canonical VARCHAR(180) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, ADD email_canonical VARCHAR(180) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, ADD salt VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON kimai2_users (username_canonical)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON kimai2_users (email_canonical)');
}
}

View File

@@ -262,7 +262,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
if ($query->getRole() !== null) {
$rolesWhere = 'u.roles LIKE :role';
$qb->setParameter('role', '%' . $query->getRole() . '%');
// a hack as FOSUserBundle does not save the ROLE_USER in the database as it is the default role
// a workaround, because ROLE_USER is not saved in the database
if ($query->getRole() === User::ROLE_USER) {
$rolesWhere .= ' OR u.roles LIKE :role1';
$qb->setParameter('role1', '%{}');

View File

@@ -0,0 +1,75 @@
<?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\Firewall;
use App\Saml\SamlAuthFactory;
use App\Saml\Token\SamlToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener;
class SamlListener extends AbstractAuthenticationListener
{
/**
* @var SamlAuthFactory
*/
protected $authFactory;
public function setAuth(SamlAuthFactory $authFactory): void
{
$this->authFactory = $authFactory;
}
/**
* Performs authentication.
*
* @param Request $request A Request instance
* @return TokenInterface|Response|null The authenticated token, null if full authentication is not possible, or a Response
*
* @throws AuthenticationException if the authentication fails
* @throws \Exception if attribute set by "username_attribute" option not found
*/
protected function attemptAuthentication(Request $request)
{
$oneLoginAuth = $this->authFactory->create();
$oneLoginAuth->processResponse();
if ($oneLoginAuth->getErrors()) {
$this->logger->error($oneLoginAuth->getLastErrorReason());
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}
$attributes = [];
if (isset($this->options['use_attribute_friendly_name']) && $this->options['use_attribute_friendly_name']) {
$attributes = $oneLoginAuth->getAttributesWithFriendlyName();
} else {
$attributes = $oneLoginAuth->getAttributes();
}
$attributes['sessionIndex'] = $oneLoginAuth->getSessionIndex();
$token = new SamlToken();
$token->setAttributes($attributes);
if (isset($this->options['username_attribute'])) {
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
$this->logger->error(sprintf('Found attributes: %s', print_r($attributes, true)));
throw new \Exception(sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
}
$username = $attributes[$this->options['username_attribute']][0];
} else {
$username = $oneLoginAuth->getNameId();
}
$token->setUser($username);
return $this->authenticationManager->authenticate($token);
}
}

View File

@@ -9,8 +9,8 @@
namespace App\Saml\Logout;
use App\Saml\SamlAuth;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use App\Saml\SamlAuthFactory;
use App\Saml\Token\SamlTokenInterface;
use OneLogin\Saml2\Error;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -20,11 +20,11 @@ use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
final class SamlLogoutHandler implements LogoutHandlerInterface
{
/**
* @var SamlAuth
* @var SamlAuthFactory
*/
private $samlAuth;
public function __construct(SamlAuth $samlAuth)
public function __construct(SamlAuthFactory $samlAuth)
{
$this->samlAuth = $samlAuth;
}
@@ -44,12 +44,14 @@ final class SamlLogoutHandler implements LogoutHandlerInterface
return;
}
$samlAuth = $this->samlAuth->create();
try {
$this->samlAuth->processSLO();
$samlAuth->processSLO();
} catch (Error $e) {
if (!empty($this->samlAuth->getSLOurl())) {
if (!empty($samlAuth->getSLOurl())) {
$sessionIndex = $token->hasAttribute('sessionIndex') ? $token->getAttribute('sessionIndex') : null;
$this->samlAuth->logout(null, [], $token->getUsername(), $sessionIndex);
$samlAuth->logout(null, [], $token->getUsername(), $sessionIndex);
}
}
}

View File

@@ -9,11 +9,12 @@
namespace App\Saml\Provider;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Saml\SamlTokenFactory;
use App\Saml\Token\SamlTokenInterface;
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;
@@ -22,29 +23,19 @@ 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;
private $configuration;
public function __construct(UserRepository $repository, UserProviderInterface $userProvider, SamlTokenFactory $tokenFactory, SamlUserFactory $userFactory)
public function __construct(UserRepository $repository, UserProviderInterface $userProvider, SamlTokenFactory $tokenFactory, SamlUserFactory $userFactory, SystemConfiguration $configuration)
{
$this->repository = $repository;
$this->userProvider = $userProvider;
$this->tokenFactory = $tokenFactory;
$this->userFactory = $userFactory;
$this->configuration = $configuration;
}
/**
@@ -83,6 +74,10 @@ final class SamlProvider implements AuthenticationProviderInterface
public function supports(TokenInterface $token)
{
if (!$this->configuration->isSamlActive()) {
return false;
}
return $token instanceof SamlTokenInterface;
}
}

View File

@@ -1,26 +0,0 @@
<?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,39 @@
<?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 App\Configuration\SamlConfiguration;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Utils;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @final
*/
class SamlAuthFactory
{
private $request;
private $configuration;
public function __construct(RequestStack $request, SamlConfiguration $configuration)
{
$this->request = $request;
$this->configuration = $configuration;
}
public function create(): Auth
{
if (null !== $this->request->getMasterRequest() && $this->request->getMasterRequest()->isFromTrustedProxy()) {
Utils::setProxyVars(true);
}
return new Auth($this->configuration->getConnection());
}
}

View File

@@ -9,15 +9,11 @@
namespace App\Saml;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlToken;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenFactoryInterface;
use App\Saml\Token\SamlToken;
final class SamlTokenFactory implements SamlTokenFactoryInterface
final class SamlTokenFactory
{
/**
* {@inheritdoc}
*/
public function createToken($user, array $attributes, array $roles)
public function createToken($user, array $attributes, array $roles): SamlToken
{
$token = new SamlToken($roles);
$token->setUser($user);

View File

@@ -0,0 +1,20 @@
<?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\Token;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
class SamlToken extends AbstractToken implements SamlTokenInterface
{
public function getCredentials()
{
return null;
}
}

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\Saml\Token;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
interface SamlTokenInterface extends TokenInterface
{
}

View File

@@ -9,38 +9,22 @@
namespace App\Saml\User;
use App\Configuration\SamlConfiguration;
use App\Entity\User;
use Hslavich\OneloginSamlBundle\Security\Authentication\Token\SamlTokenInterface;
use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface;
use App\Saml\Token\SamlTokenInterface;
final class SamlUserFactory implements SamlUserFactoryInterface
final class SamlUserFactory
{
/**
* @var array
*/
private $mapping;
/**
* @var string
*/
private $groupAttribute;
/**
* @var array
*/
private $groupMapping;
private $configuration;
public function __construct(array $attributes)
public function __construct(SamlConfiguration $configuration)
{
$this->mapping = $attributes['mapping'];
$this->groupAttribute = $attributes['roles']['attribute'];
$this->groupMapping = $attributes['roles']['mapping'];
$this->configuration = $configuration;
}
/**
* @param SamlTokenInterface $token
* @return User
*/
public function createUser(SamlTokenInterface $token)
public function createUser(SamlTokenInterface $token): User
{
// Not using UserService: user settings should be set via SAML attributes
$user = new User();
$user->setEnabled(true);
$user->setUsername($token->getUsername());
@@ -52,17 +36,20 @@ final class SamlUserFactory implements SamlUserFactoryInterface
public function hydrateUser(User $user, SamlTokenInterface $token): void
{
$groupAttribute = $this->configuration->getRolesAttribute();
$groupMapping = $this->configuration->getRolesMapping();
// extract user roles from a special saml attribute
if (!empty($this->groupAttribute) && $token->hasAttribute($this->groupAttribute)) {
if (!empty($groupAttribute) && $token->hasAttribute($groupAttribute)) {
$groupMap = [];
foreach ($this->groupMapping as $mapping) {
foreach ($groupMapping as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$groupMap[$attribute] = $field;
}
$roles = [];
$samlGroups = $token->getAttribute($this->groupAttribute);
$samlGroups = $token->getAttribute($groupAttribute);
foreach ($samlGroups as $groupName) {
if (\array_key_exists($groupName, $groupMap)) {
$roles[] = $groupMap[$groupName];
@@ -71,7 +58,9 @@ final class SamlUserFactory implements SamlUserFactoryInterface
$user->setRoles($roles);
}
foreach ($this->mapping as $mapping) {
$mappingConfig = $this->configuration->getAttributeMapping();
foreach ($mappingConfig as $mapping) {
$field = $mapping['kimai'];
$attribute = $mapping['saml'];
$value = $this->getPropertyValue($token, $attribute);

View File

@@ -11,12 +11,14 @@ namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Exception;
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\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final class DoctrineUserProvider implements UserProviderInterface
final class DoctrineUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
/**
* @var UserRepository
@@ -51,7 +53,7 @@ final class DoctrineUserProvider implements UserProviderInterface
/**
* {@inheritdoc}
*/
public function refreshUser(SecurityUserInterface $user)
public function refreshUser(UserInterface $user)
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Expected an instance of %s, but got "%s".', User::class, \get_class($user)));
@@ -74,4 +76,15 @@ final class DoctrineUserProvider implements UserProviderInterface
{
return $class === User::class;
}
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
if ($user instanceof User) {
try {
$user->setPassword($newEncodedPassword);
$this->repository->saveUser($user);
} catch (Exception $e) {
}
}
}
}

View File

@@ -0,0 +1,91 @@
<?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\Configuration\SystemConfiguration;
use App\Ldap\LdapUserProvider;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private $providers;
private $provider;
private $configuration;
/**
* @param iterable|UserProviderInterface[] $providers
*/
public function __construct(iterable $providers, SystemConfiguration $configuration)
{
$this->providers = $providers;
$this->configuration = $configuration;
}
private function getInternalProvider(): ChainUserProvider
{
if ($this->provider === null) {
$activated = [];
foreach ($this->providers as $provider) {
if ($provider instanceof LdapUserProvider) {
if (!$this->configuration->isLdapActive()) {
continue;
}
}
$activated[] = $provider;
}
$this->provider = new ChainUserProvider(new \ArrayIterator($activated));
}
return $this->provider;
}
/**
* @return array
*/
public function getProviders()
{
return $this->getInternalProvider()->getProviders();
}
/**
* {@inheritdoc}
*/
public function loadUserByUsername($username)
{
return $this->getInternalProvider()->loadUserByUsername($username);
}
/**
* {@inheritdoc}
*/
public function refreshUser(UserInterface $user)
{
return $this->getInternalProvider()->refreshUser($user);
}
/**
* {@inheritdoc}
*/
public function supportsClass($class)
{
return $this->getInternalProvider()->supportsClass($class);
}
/**
* {@inheritdoc}
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
$this->getInternalProvider()->upgradePassword($user, $newEncodedPassword);
}
}

View File

@@ -19,21 +19,16 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface;
class TokenAuthenticator extends AbstractGuardAuthenticator
class TokenAuthenticator extends AbstractGuardAuthenticator implements PasswordAuthenticatedInterface
{
public const HEADER_USERNAME = 'X-AUTH-USER';
public const HEADER_TOKEN = 'X-AUTH-TOKEN';
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
/**
* @var EncoderFactoryInterface
*/
protected $encoderFactory;
private $encoderFactory;
/**
* @param EncoderFactoryInterface $encoderFactory
*/
public function __construct(EncoderFactoryInterface $encoderFactory)
{
$this->encoderFactory = $encoderFactory;
@@ -163,4 +158,13 @@ class TokenAuthenticator extends AbstractGuardAuthenticator
{
return false;
}
public function getPassword($credentials): ?string
{
if (!\is_array($credentials) || !\array_key_exists('token', $credentials) || empty($credentials['token'])) {
return null;
}
return $credentials['token'];
}
}

72
src/User/LoginManager.php Normal file
View File

@@ -0,0 +1,72 @@
<?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\User;
use App\Entity\User;
use App\Event\UserInteractiveLoginEvent;
use App\Security\UserChecker;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final class LoginManager
{
private $tokenStorage;
private $userChecker;
private $sessionStrategy;
private $requestStack;
private $eventDispatcher;
private $rememberMeService;
public function __construct(
TokenStorageInterface $tokenStorage,
UserChecker $userChecker,
SessionAuthenticationStrategyInterface $sessionStrategy,
RequestStack $requestStack,
EventDispatcherInterface $eventDispatcher,
RememberMeServicesInterface $rememberMeService = null
) {
$this->tokenStorage = $tokenStorage;
$this->userChecker = $userChecker;
$this->sessionStrategy = $sessionStrategy;
$this->requestStack = $requestStack;
$this->eventDispatcher = $eventDispatcher;
$this->rememberMeService = $rememberMeService;
}
public function logInUser(User $user, Response $response = null)
{
$this->userChecker->checkPreAuth($user);
$token = $this->createToken('secured_area', $user);
$request = $this->requestStack->getCurrentRequest();
if (null !== $request) {
$this->sessionStrategy->onAuthentication($request, $token);
if (null !== $response && null !== $this->rememberMeService) {
$this->rememberMeService->loginSuccess($request, $response, $token);
}
}
$this->tokenStorage->setToken($token);
$this->eventDispatcher->dispatch(new UserInteractiveLoginEvent($user));
}
private function createToken(string $firewall, User $user): UsernamePasswordToken
{
return new UsernamePasswordToken($user, null, $firewall, $user->getRoles());
}
}

168
src/User/UserService.php Normal file
View File

@@ -0,0 +1,168 @@
<?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\User;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\UserCreateEvent;
use App\Event\UserCreatePostEvent;
use App\Event\UserCreatePreEvent;
use App\Event\UserUpdatePostEvent;
use App\Event\UserUpdatePreEvent;
use App\Repository\UserRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @final
*/
class UserService
{
private $repository;
private $dispatcher;
private $validator;
private $configuration;
private $encoderFactory;
public function __construct(UserRepository $repository, EventDispatcherInterface $dispatcher, ValidatorInterface $validator, SystemConfiguration $configuration, UserPasswordEncoderInterface $encoderFactory)
{
$this->repository = $repository;
$this->dispatcher = $dispatcher;
$this->validator = $validator;
$this->configuration = $configuration;
$this->encoderFactory = $encoderFactory;
}
public function createNewUser(): User
{
$user = new User();
$user->setEnabled(true);
$user->setTimezone($this->configuration->getUserDefaultTimezone());
$user->setLanguage($this->configuration->getUserDefaultLanguage());
$user->setPreferenceValue(UserPreference::SKIN, $this->configuration->getUserDefaultTheme());
// Attention: PrepareUserEvent cannot be dispatched on console, as it calls isGranted()
$this->dispatcher->dispatch(new UserCreateEvent($user));
return $user;
}
public function saveNewUser(User $user): User
{
if (null !== $user->getId()) {
throw new InvalidArgumentException('Cannot create user, already persisted');
}
$this->validateUser($user, ['Registration', 'UserCreate']);
$this->hashPassword($user);
$this->hashApiToken($user);
$this->dispatcher->dispatch(new UserCreatePreEvent($user));
$this->repository->saveUser($user);
$this->dispatcher->dispatch(new UserCreatePostEvent($user));
return $user;
}
/**
* @param User $user
* @param string[] $groups
* @throws ValidationFailedException
*/
private function validateUser(User $user, array $groups = []): void
{
$errors = $this->validator->validate($user, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
}
}
public function updateUser(User $user, array $groups = []): User
{
$this->validateUser($user, $groups);
$this->hashPassword($user);
$this->hashApiToken($user);
$this->dispatcher->dispatch(new UserUpdatePreEvent($user));
$this->repository->saveUser($user);
$this->dispatcher->dispatch(new UserUpdatePostEvent($user));
return $user;
}
public function findUserByUsernameOrThrowException(string $username): User
{
$user = $this->findUserByName($username);
if ($user === null) {
throw new \InvalidArgumentException(sprintf('User identified by "%s" username does not exist.', $username));
}
return $user;
}
public function findUserByUsernameOrEmail(string $usernameOrEmail): ?User
{
return $this->repository->loadUserByUsername($usernameOrEmail);
}
public function findUserByEmail(string $email): ?User
{
return $this->repository->findOneBy(['email' => $email]);
}
public function findUserByName(string $name): ?User
{
return $this->repository->findOneBy(['username' => $name]);
}
public function findUserByConfirmationToken(string $token): ?User
{
return $this->repository->findOneBy(['confirmationToken' => $token]);
}
public function generateSecurityToken(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private function hashPassword(User $user)
{
$plain = $user->getPlainPassword();
if ($plain === null || 0 === \strlen($plain)) {
return;
}
$password = $this->encoderFactory->encodePassword($user, $plain);
$user->setPassword($password);
$user->eraseCredentials();
}
private function hashApiToken(User $user)
{
$plain = $user->getPlainApiToken();
if ($plain === null || 0 === \strlen($plain)) {
return;
}
$password = $this->encoderFactory->encodePassword($user, $plain);
$user->setApiToken($password);
$user->eraseCredentials();
}
}

View File

@@ -0,0 +1,70 @@
<?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\Utils;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
final class CommandStyle
{
private $input;
private $output;
private $style;
public function __construct(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;
}
private function getStyle(): SymfonyStyle
{
if ($this->style === null) {
$this->style = new SymfonyStyle($this->input, $this->output);
}
return $this->style;
}
public function success($message): void
{
$this->getStyle()->success($message);
}
public function error($message): void
{
$this->getStyle()->error($message);
}
public function warning($message): void
{
$this->getStyle()->warning($message);
}
public function validationError(ValidationFailedException $exception): void
{
$errors = $exception->getViolations();
if ($errors->count() > 0) {
$style = $this->getStyle();
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$style->error(
$error->getPropertyPath()
. ' (' . (\is_array($value) ? implode(',', $value) : $value) . ')'
. "\n "
. $error->getMessage()
);
}
}
}
}

View File

@@ -0,0 +1,35 @@
<?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\Validator\Constraints;
use Doctrine\Common\Annotations\Annotation\Target;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
*/
class User extends Constraint
{
public const USER_EXISTING_EMAIL = 'kimai-user-00';
public const USER_EXISTING_NAME = 'kimai-user-01';
protected static $errorNames = [
self::USER_EXISTING_EMAIL => 'The email is already used.',
self::USER_EXISTING_NAME => 'The username is already used.',
];
public $message = 'The user has invalid settings.';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,71 @@
<?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\Validator\Constraints;
use App\Entity\User as UserEntity;
use App\User\UserService;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UserValidator extends ConstraintValidator
{
private $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
/**
* @param UserEntity $value
* @param Constraint $constraint
*/
public function validate($value, Constraint $constraint)
{
if (!($constraint instanceof User)) {
throw new UnexpectedTypeException($constraint, User::class);
}
if (!\is_object($value) || !($value instanceof UserEntity)) {
return;
}
$this->validateUser($value, $this->context);
}
protected function validateUser(UserEntity $user, ExecutionContextInterface $context)
{
if ($user->getEmail() !== null) {
$existingByEmail = $this->userService->findUserByEmail($user->getEmail());
if (null !== $existingByEmail && $user->getId() !== $existingByEmail->getId()) {
$context->buildViolation(User::getErrorName(User::USER_EXISTING_EMAIL))
->atPath('email')
->setTranslationDomain('validators')
->setCode(User::USER_EXISTING_EMAIL)
->addViolation();
}
}
if ($user->getUsername() !== null) {
$existingByName = $this->userService->findUserByName($user->getUsername());
if (null !== $existingByName && $user->getId() !== $existingByName->getId()) {
$context->buildViolation(User::getErrorName(User::USER_EXISTING_NAME))
->atPath('username')
->setTranslationDomain('validators')
->setCode(User::USER_EXISTING_NAME)
->addViolation();
}
}
}
}

View File

@@ -147,9 +147,6 @@
"handcraftedinthealps/rest-routing-bundle": {
"version": "1.0.0"
},
"hslavich/oneloginsaml-bundle": {
"version": "v1.4.1"
},
"illuminate/cache": {
"version": "v6.0.4"
},
@@ -186,9 +183,6 @@
"kevinpapst/adminlte-bundle": {
"version": "0.3"
},
"kimai/user-bundle": {
"version": "dev-master"
},
"laminas/laminas-code": {
"version": "3.4.1"
},
@@ -636,6 +630,9 @@
"symfony/polyfill-php80": {
"version": "v1.17.1"
},
"symfony/polyfill-php81": {
"version": "v1.23.0"
},
"symfony/polyfill-uuid": {
"version": "v1.22.0"
},

View File

@@ -16,17 +16,12 @@
{% block page_content_start %}
{% if app.session and app.session.started and app.session.flashbag.peekAll|length > 0 %}
{% set close = adminlte_close_alert|default(true) %}
{% set domain = 'flashmessages' %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function(options) {
var ALERT = options.detail.kimai.getPlugin('alert');
{% for type, messages in app.session.flashbag.all %}
{% for message in messages %}
{% if type == 'fos_user_success' %}
{% set type = 'success' %}
{% set domain = 'FOSUserBundle' %}
{% endif %}
{% if type == 'error' %}
ALERT.error('{{ message|trans({}, domain) }}');
{% elseif type == 'warning' %}

View File

@@ -1,23 +0,0 @@
{% extends '@AdminLTE/FOSUserBundle/Registration/confirmed.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
{% block head %}
{{ parent() }}
{% include 'partials/head.html.twig' %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::HTML_HEAD')) %}
{{ event.content|raw }}
{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::STYLESHEET')) %}
{{ event.content|raw }}
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
{{ event.content|raw }}
{% endblock %}

View File

@@ -1,23 +0,0 @@
{% extends '@AdminLTE/FOSUserBundle/Resetting/request.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
{% block head %}
{{ parent() }}
{% include 'partials/head.html.twig' %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::HTML_HEAD')) %}
{{ event.content|raw }}
{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::STYLESHEET')) %}
{{ event.content|raw }}
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
{{ event.content|raw }}
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% extends '@AdminLTE/FOSUserBundle/Resetting/request.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
{% block head %}
{{ parent() }}
{% include 'partials/head.html.twig' %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::HTML_HEAD')) %}
{{ event.content|raw }}
{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::STYLESHEET')) %}
{{ event.content|raw }}
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
{{ event.content|raw }}
{% endblock %}
{% block login_form %}
{% trans_default_domain 'FOSUserBundle' %}
{{ form_start(form, { 'action': path('fos_user_resetting_reset', {'token': token}), 'attr': { 'class': 'fos_user_resetting_reset' } }) }}
{{ form_widget(form) }}
<div class="row">
<div class="col-xs-12">
<button type="submit" class="btn btn-primary btn-block btn-flat">{{ 'resetting.request.submit'|trans }}</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -1,32 +0,0 @@
{% extends '@AdminLTE/FOSUserBundle/Security/login.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
{% block head %}
{{ parent() }}
{% include 'partials/head.html.twig' %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::HTML_HEAD')) %}
{{ event.content|raw }}
{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::STYLESHEET')) %}
{{ event.content|raw }}
{% endblock %}
{% block javascripts %}
{{ 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

@@ -0,0 +1,57 @@
{% extends '@AdminLTE/FOSUserBundle/Security/login.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
{% block head %}
{{ parent() }}
{% include 'partials/head.html.twig' %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::HTML_HEAD')) %}
{{ event.content|raw }}
{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::STYLESHEET')) %}
{{ event.content|raw }}
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
{{ event.content|raw }}
{% endblock %}
{% block login_social_auth %}
{% if kimai_config.samlActive %}
{% set class = 'btn-primary' %}
{% if kimai_config.loginFormActive %}
{% set class = 'btn-google' %}
<hr>
{% endif %}
<a href="{{ path('saml_login') }}" class="btn btn-block {{ class }}">
{{ kimai_config.samlTitle|trans }}
</a>
<br>
{% endif %}
{% endblock %}
{% block login_form %}
{% if kimai_config.loginFormActive %}
{{ parent() }}
{% endif %}
{% endblock %}
{% block login_actions %}
{% if kimai_config.passwordResetActive %}
<a href="{{ path('fos_user_resetting_request') }}">
{{ 'I forgot my password'|trans({}, 'AdminLTEBundle') }}
</a>
<br>
{% endif %}
{% if kimai_config.selfRegistrationActive %}
<a href="{{ path('fos_user_registration_register') }}">
{{ 'Register a new account'|trans({}, 'AdminLTEBundle') }}
</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'security/password-reset/layout.html.twig' %}
{% block login_form %}
<p>
{{ 'resetting.check_email'|trans({'%tokenLifetime%': tokenLifetime})|nl2br }}
</p>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends '@AdminLTE/FOSUserBundle/layout.html.twig' %}
{% extends '@AdminLTE/FOSUserBundle/Resetting/request.html.twig' %}
{% block logo_login %}{% include 'partials/logo_login.html.twig' %}{% endblock %}
{% block title %}{{- get_title() -}}{% endblock %}
@@ -20,4 +20,4 @@
{{ encore_entry_script_tags('app') }}
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
{{ event.content|raw }}
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'security/password-reset/layout.html.twig' %}

View File

@@ -0,0 +1,16 @@
{% extends 'security/password-reset/layout.html.twig' %}
{% block login_form %}
{{ form_start(form, { 'action': path('fos_user_resetting_reset', {'token': token}), 'attr': { 'class': 'fos_user_resetting_reset' } }) }}
{{ form_widget(form) }}
<div class="row">
<div class="col-xs-12">
<button type="submit" class="btn btn-primary btn-block btn-flat">{{ 'Reset your password'|trans({}, 'AdminLTEBundle') }}</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'security/self-registration/layout.html.twig' %}
{% block login_form %}
<p>
{{ 'registration.check_email'|trans({'%email%': user.email}) }}
</p>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends 'security/self-registration/layout.html.twig' %}
{% block login_form %}
<p>
{{ 'registration.confirmed'|trans({'%username%': user.username}, 'AdminLTEBundle') }}
</p>
{% endblock %}
{% block login_actions %}
<br>
<a href="{{ path('homepage') }}">
{{ 'Show homepage'|trans({}, 'AdminLTEBundle') }}
</a>
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'security/self-registration/layout.html.twig' %}

View File

@@ -0,0 +1,102 @@
<?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\Command;
use App\Command\ActivateUserCommand;
use App\Entity\User;
use App\Repository\UserRepository;
use App\User\UserService;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @covers \App\Command\ActivateUserCommand
* @group integration
*/
class ActivateUserCommandTest extends KernelTestCase
{
/**
* @var Application
*/
private $application;
protected function setUp(): void
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();
$userService = $container->get(UserService::class);
$this->application->add(new ActivateUserCommand($userService));
}
public function testCommandName()
{
$application = $this->application;
$command = $application->find('kimai:user:activate');
self::assertInstanceOf(ActivateUserCommand::class, $command);
// test alias
$command = $application->find('fos:user:activate');
self::assertInstanceOf(ActivateUserCommand::class, $command);
}
protected function callCommand(?string $username)
{
$command = $this->application->find('kimai:user:activate');
$input = [
'command' => $command->getName(),
];
if ($username !== null) {
$input['username'] = $username;
}
$commandTester = new CommandTester($command);
$commandTester->execute($input);
return $commandTester;
}
public function testActivate()
{
$commandTester = $this->callCommand('chris_user');
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] User "chris_user" has been activated.', $output);
$container = self::$kernel->getContainer();
/** @var UserRepository $userRepository */
$userRepository = $container->get('doctrine')->getRepository(User::class);
$user = $userRepository->loadUserByUsername('chris_user');
self::assertInstanceOf(User::class, $user);
self::assertTrue($user->isEnabled());
}
public function testActivateOnActiveUser()
{
$commandTester = $this->callCommand('susan_super');
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[WARNING] User "susan_super" is already active.', $output);
}
public function testWithMissingUsername()
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").');
$this->callCommand(null);
}
}

View File

@@ -0,0 +1,109 @@
<?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\Command;
use App\Command\ChangePasswordCommand;
use App\Entity\User;
use App\Repository\UserRepository;
use App\User\UserService;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @covers \App\Command\ChangePasswordCommand
* @group integration
*/
class ChangePasswordCommandTest extends KernelTestCase
{
/**
* @var Application
*/
private $application;
protected function setUp(): void
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();
$userService = $container->get(UserService::class);
$this->application->add(new ChangePasswordCommand($userService));
}
public function testCommandName()
{
$application = $this->application;
$command = $application->find('kimai:user:password');
self::assertInstanceOf(ChangePasswordCommand::class, $command);
// test alias
$command = $application->find('fos:user:change-password');
self::assertInstanceOf(ChangePasswordCommand::class, $command);
}
protected function callCommand(?string $username, ?string $password)
{
$command = $this->application->find('kimai:user:password');
$input = [
'command' => $command->getName(),
];
if ($username !== null) {
$input['username'] = $username;
}
if ($password !== null) {
$input['password'] = $password;
}
$commandTester = new CommandTester($command);
$commandTester->execute($input);
return $commandTester;
}
public function testChangePassword()
{
$commandTester = $this->callCommand('john_user', '0987654321');
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] Changed password for user "john_user".', $output);
$container = self::$kernel->getContainer();
/** @var UserRepository $userRepository */
$userRepository = $container->get('doctrine')->getRepository(User::class);
$user = $userRepository->loadUserByUsername('john_user');
self::assertInstanceOf(User::class, $user);
$container = self::$kernel->getContainer();
$encoderService = $container->get('security.password_encoder');
self::assertTrue($encoderService->isPasswordValid($user, '0987654321'));
}
public function testWithMissingUsername()
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "username").');
$this->callCommand(null, '1234567890');
}
public function testWithMissingPassword()
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "password").');
$this->callCommand('1234567890', null);
}
}

Some files were not shown because too many files have changed in this diff Show More