LDAP authentication support (#815)

This commit is contained in:
Kevin Papst
2019-06-07 22:48:39 +02:00
committed by GitHub
parent bcf1ebd778
commit 0c0e9c2f71
99 changed files with 3542 additions and 926 deletions

View File

@@ -0,0 +1,43 @@
<?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;
class LdapConfiguration
{
/**
* @var array
*/
protected $settings = [];
public function __construct(array $settings)
{
$this->settings = $settings;
}
public function isActivated(): bool
{
return (bool) $this->settings['active'];
}
public function getRoleParameters(): array
{
return (array) $this->settings['role'];
}
public function getUserParameters(): array
{
return (array) $this->settings['user'];
}
public function getConnectionParameters(): array
{
return (array) $this->settings['connection'];
}
}

View File

@@ -29,12 +29,12 @@ class AppExtension extends Extension
$config = $this->processConfiguration($configuration, $configs);
} catch (InvalidConfigurationException $e) {
trigger_error('Found invalid "kimai" configuration: ' . $e->getMessage());
$config = [];
throw $e;
}
// @deprecated since 0.9, duration_only will be removed with 1.0
if (isset($config['timesheet']['duration_only'])) {
trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED);
@trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED);
if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) {
trigger_error('Found ambiguous configuration. Please remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.');
}
@@ -60,6 +60,25 @@ class AppExtension extends Extension
$container->setParameter('kimai.timesheet', $config['timesheet']);
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
$this->setLdapParameter($config['ldap'], $container);
}
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);
}
/**

View File

@@ -62,6 +62,7 @@ class Configuration implements ConfigurationInterface
->append($this->getWidgetsNode())
->append($this->getDefaultsNode())
->append($this->getPermissionsNode())
->append($this->getLdapNode())
->end()
->end();
@@ -100,7 +101,7 @@ class Configuration implements ConfigurationInterface
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([])
->end()
->integerNode('begin')
@@ -141,7 +142,7 @@ class Configuration implements ConfigurationInterface
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([])
->end()
->floatNode('factor')
@@ -258,7 +259,7 @@ class Configuration implements ConfigurationInterface
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->prototype('integer')->end()
->integerPrototype()->end()
->defaultValue([1, 2, 3, 4, 5])
->end()
->scalarNode('begin')->defaultValue('08:00')->end()
@@ -449,7 +450,7 @@ class Configuration implements ConfigurationInterface
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([])
->end()
->end()
@@ -459,7 +460,7 @@ class Configuration implements ConfigurationInterface
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([])
->end()
->end()
@@ -469,7 +470,7 @@ class Configuration implements ConfigurationInterface
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([])
->end()
->defaultValue([
@@ -484,4 +485,130 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getLdapNode()
{
$treeBuilder = new TreeBuilder('ldap');
$node = $treeBuilder->getRootNode();
$node
->addDefaultsIfNotSet()
->children()
->booleanNode('active')->defaultFalse()->end()
->arrayNode('connection')
->addDefaultsIfNotSet()
->children()
->scalarNode('host')->defaultNull()->end()
->scalarNode('port')->defaultValue(389)->end()
->scalarNode('useStartTls')->defaultFalse()->end()
->scalarNode('useSsl')->defaultFalse()->end()
->scalarNode('username')->end()
->scalarNode('password')->end()
->scalarNode('bindRequiresDn')->defaultTrue()->end()
->scalarNode('baseDn')->end()
->scalarNode('accountCanonicalForm')->end()
->scalarNode('accountDomainName')->end()
->scalarNode('accountDomainNameShort')->end()
->scalarNode('accountFilterFormat')
->defaultNull()
->validate()
->ifTrue(static function ($v) {
if (empty($v)) {
return false;
}
if ($v[0] !== '(' || (substr_count($v, '(') !== substr_count($v, ')'))) {
return true;
}
return (substr_count($v, '%s') !== 1);
})
->thenInvalid('The accountFilterFormat must be enclosed by a matching number of parentheses "()" and contain one "%%s" replacer for the username')
->end()
->end()
->scalarNode('allowEmptyPassword')->end()
->scalarNode('optReferrals')->end()
->scalarNode('tryUsernameSplit')->end()
->scalarNode('networkTimeout')->end()
->end()
->validate()
->ifTrue(static function ($v) {
return $v['useSsl'] && $v['useStartTls'];
})
->thenInvalid('The ldap.connection.useSsl and ldap.connection.useStartTls options are mutually exclusive.')
->end()
->end()
->arrayNode('user')
->addDefaultsIfNotSet()
->children()
->scalarNode('baseDn')->defaultNull()->end()
->scalarNode('filter')
->defaultValue('')
->validate()
->ifTrue(static function ($v) {
if (empty($v)) {
return false;
}
if ($v[0] !== '(' || (substr_count($v, '(') !== substr_count($v, ')'))) {
return true;
}
return (stripos($v, '%s') !== false);
})
->thenInvalid('The ldap.user.filter must be enclosed by a matching number of parentheses "()" and must NOT contain a "%%s" replacer')
->end()
->end()
->scalarNode('usernameAttribute')->defaultValue('uid')->end()
->arrayNode('attributes')
->defaultValue([])
->arrayPrototype()
->children()
->scalarNode('ldap_attr')->isRequired()->cannotBeEmpty()->end()
->scalarNode('user_method')->isRequired()->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('role')
->addDefaultsIfNotSet()
->children()
->scalarNode('baseDn')->defaultNull()->end()
->scalarNode('filter')->end()
->scalarNode('usernameAttribute')->defaultValue('dn')->end()
->scalarNode('nameAttribute')->defaultValue('cn')->end()
->scalarNode('userDnAttribute')->defaultValue('member')->end()
->arrayNode('groups')
->defaultValue([])
->arrayPrototype()
->children()
->scalarNode('ldap_value')->isRequired()->cannotBeEmpty()->end()
->scalarNode('role')->isRequired()->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->validate()
->ifTrue(static function ($v) {
return $v['active'] && !extension_loaded('ldap');
})
->thenInvalid('LDAP is activated, but the LDAP PHP extension is not loaded.')
->end()
->validate()
->ifTrue(static function ($v) {
return $v['active'] && empty($v['connection']['host']);
})
->thenInvalid('The "ldap.connection.host" config must be set if LDAP is activated.')
->end()
->validate()
->ifTrue(static function ($v) {
return $v['active'] && empty($v['user']['baseDn']);
})
->thenInvalid('The "ldap.user.baseDn" config must be set if LDAP is activated.')
->end()
;
return $node;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -28,7 +30,7 @@ class Activity
private $id;
/**
* @var Project
* @var Project|null
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="activities")
* @ORM\JoinColumn(onDelete="CASCADE")
@@ -60,7 +62,7 @@ class Activity
private $visible = true;
/**
* @var Timesheet[]
* @var Timesheet[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="App\Entity\Timesheet", mappedBy="activity")
*/
@@ -70,94 +72,68 @@ class Activity
use RatesTrait;
use ColorTrait;
/**
* @return int
*/
public function getId()
public function __construct()
{
$this->timesheets = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
/**
* @return Timesheet[]
* @return Collection<Timesheet>
*/
public function getTimesheets(): array
public function getTimesheets(): Collection
{
return $this->timesheets;
}
/**
* @return Project
*/
public function getProject()
public function getProject(): ?Project
{
return $this->project;
}
/**
* @param Project $project
* @return Activity
*/
public function setProject($project)
public function setProject(?Project $project): Activity
{
$this->project = $project;
return $this;
}
/**
* @param string $name
* @return Activity
*/
public function setName($name)
public function setName(string $name): Activity
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $comment
* @return Activity
*/
public function setComment($comment)
public function setComment(?string $comment): Activity
{
$this->comment = $comment;
return $this;
}
/**
* @return string
*/
public function getComment()
public function getComment(): ?string
{
return $this->comment;
}
/**
* @param bool $visible
* @return Activity
*/
public function setVisible($visible)
public function setVisible(bool $visible): Activity
{
$this->visible = $visible;
return $this;
}
/**
* @return bool
*/
public function getVisible()
public function getVisible(): bool
{
return $this->visible;
}

View File

@@ -48,10 +48,7 @@ class Configuration
*/
private $value;
/**
* @return int
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
@@ -76,14 +73,17 @@ class Configuration
}
/**
* Given $value will not be serialized before its stored, so it should be a scalar type.
* Given $value will not be serialized before its stored, so it should be a scalar type
* that can be casted to string.
*
* @param mixed $value
* @param string|null|int|bool $value
* @return Configuration
*/
public function setValue($value): Configuration
{
$this->value = $value;
if (null !== $value) {
$this->value = (string) $value;
}
return $this;
}

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -53,7 +55,7 @@ class Customer
private $comment;
/**
* @var Project[]
* @var Project[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="App\Entity\Project", mappedBy="customer")
*/
@@ -151,362 +153,200 @@ class Customer
use RatesTrait;
use ColorTrait;
/**
* @return int
*/
public function getId()
public function __construct()
{
$this->projects = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
/**
* Set name
*
* @param string $name
* @return Customer
*/
public function setName($name)
public function setName(string $name): Customer
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* @return string
*/
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $number
* @return Customer
*/
public function setNumber(string $number)
public function setNumber(?string $number): Customer
{
$this->number = $number;
return $this;
}
/**
* @return string
*/
public function getNumber(): ?string
{
return $this->number;
}
/**
* Set comment
*
* @param string $comment
* @return Customer
*/
public function setComment($comment)
public function setComment(?string $comment): Customer
{
$this->comment = $comment;
return $this;
}
/**
* Get comment
*
* @return string
*/
public function getComment()
public function getComment(): ?string
{
return $this->comment;
}
/**
* Set visible
*
* @param bool $visible
* @return Customer
*/
public function setVisible($visible)
public function setVisible(bool $visible): Customer
{
$this->visible = $visible;
return $this;
}
/**
* Get visible
*
* @return bool
*/
public function getVisible()
public function getVisible(): bool
{
return $this->visible;
}
/**
* Set company
*
* @param string $company
* @return Customer
*/
public function setCompany($company)
public function setCompany(?string $company): Customer
{
$this->company = $company;
return $this;
}
/**
* Get company
*
* @return string
*/
public function getCompany()
public function getCompany(): ?string
{
return $this->company;
}
/**
* Set contact
*
* @param string $contact
* @return Customer
*/
public function setContact($contact)
public function setContact(?string $contact): Customer
{
$this->contact = $contact;
return $this;
}
/**
* Get contact
*
* @return string
*/
public function getContact()
public function getContact(): ?string
{
return $this->contact;
}
/**
* @param string $address
* @return Customer
*/
public function setAddress($address)
public function setAddress(?string $address): Customer
{
$this->address = $address;
return $this;
}
/**
* @return string
*/
public function getAddress()
public function getAddress(): ?string
{
return $this->address;
}
/**
* Set country
*
* @param string $country
* @return Customer
*/
public function setCountry($country)
public function setCountry(string $country): Customer
{
$this->country = $country;
return $this;
}
/**
* Get country
*
* @return string
*/
public function getCountry()
public function getCountry(): ?string
{
return $this->country;
}
/**
* @param string $currency
* @return Customer
*/
public function setCurrency($currency)
public function setCurrency(string $currency): Customer
{
$this->currency = $currency;
return $this;
}
/**
* @return string
*/
public function getCurrency()
public function getCurrency(): string
{
return $this->currency;
}
/**
* Set phone
*
* @param string $phone
* @return Customer
*/
public function setPhone($phone)
public function setPhone(?string $phone): Customer
{
$this->phone = $phone;
return $this;
}
/**
* Get phone
*
* @return string
*/
public function getPhone()
public function getPhone(): ?string
{
return $this->phone;
}
/**
* Set fax
*
* @param string $fax
* @return Customer
*/
public function setFax($fax)
public function setFax(?string $fax): Customer
{
$this->fax = $fax;
return $this;
}
/**
* Get fax
*
* @return string
*/
public function getFax()
public function getFax(): ?string
{
return $this->fax;
}
/**
* Set mobile
*
* @param string $mobile
* @return Customer
*/
public function setMobile($mobile)
public function setMobile(?string $mobile): Customer
{
$this->mobile = $mobile;
return $this;
}
/**
* Get mobile
*
* @return string
*/
public function getMobile()
public function getMobile(): ?string
{
return $this->mobile;
}
/**
* Set mail
*
* @param string $mail
* @return Customer
*/
public function setEmail($mail)
public function setEmail(?string $mail): Customer
{
$this->email = $mail;
return $this;
}
/**
* Get mail
*
* @return string
*/
public function getEmail()
public function getEmail(): ?string
{
return $this->email;
}
/**
* Set homepage
*
* @param string $homepage
* @return Customer
*/
public function setHomepage($homepage)
public function setHomepage(?string $homepage): Customer
{
$this->homepage = $homepage;
return $this;
}
/**
* Get homepage
*
* @return string
*/
public function getHomepage()
public function getHomepage(): ?string
{
return $this->homepage;
}
/**
* Set timezone
*
* @param string $timezone
* @return Customer
*/
public function setTimezone($timezone)
public function setTimezone(string $timezone): Customer
{
$this->timezone = $timezone;
return $this;
}
/**
* Get timezone
*
* @return string
*/
public function getTimezone()
public function getTimezone(): ?string
{
return $this->timezone;
}
/**
* @param Project[] $projects
* @return Customer
* @return Collection<Project>
*/
public function setProjects($projects)
{
$this->projects = $projects;
return $this;
}
/**
* @return Project[]
*/
public function getProjects()
public function getProjects(): Collection
{
return $this->projects;
}

View File

@@ -109,202 +109,125 @@ class InvoiceTemplate
*/
private $paymentTerms;
/**
* @return int
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
/**
* Set name
*
* @param string $name
* @return $this
*/
public function setName($name)
public function setName(string $name): InvoiceTemplate
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
/**
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return InvoiceTemplate
*/
public function setTitle(string $title)
public function setTitle(string $title): InvoiceTemplate
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getAddress(): ?string
{
return $this->address;
}
/**
* @param string $address
* @return InvoiceTemplate
*/
public function setAddress($address)
public function setAddress(?string $address): InvoiceTemplate
{
$this->address = $address;
return $this;
}
/**
* @return string
*/
public function getNumberGenerator(): ?string
public function getNumberGenerator(): string
{
return $this->numberGenerator;
}
/**
* @param string $numberGenerator
* @return InvoiceTemplate
*/
public function setNumberGenerator(string $numberGenerator)
public function setNumberGenerator(string $numberGenerator): InvoiceTemplate
{
$this->numberGenerator = $numberGenerator;
return $this;
}
/**
* @return int
*/
public function getDueDays(): ?int
public function getDueDays(): int
{
return $this->dueDays;
}
/**
* @param int $dueDays
* @return InvoiceTemplate
*/
public function setDueDays(int $dueDays)
public function setDueDays(int $dueDays): InvoiceTemplate
{
$this->dueDays = $dueDays;
return $this;
}
/**
* @return float
*/
public function getVat(): ?float
public function getVat(): float
{
return $this->vat;
}
/**
* @param float $vat
* @return InvoiceTemplate
*/
public function setVat(float $vat)
public function setVat(float $vat): InvoiceTemplate
{
$this->vat = $vat;
return $this;
}
/**
* @return string
*/
public function getCompany(): ?string
{
return $this->company;
}
/**
* @param string $company
* @return InvoiceTemplate
*/
public function setCompany(string $company)
public function setCompany(string $company): InvoiceTemplate
{
$this->company = $company;
return $this;
}
/**
* @return string
*/
public function getRenderer(): string
{
return $this->renderer;
}
/**
* @param string $renderer
* @return InvoiceTemplate
*/
public function setRenderer(string $renderer)
public function setRenderer(string $renderer): InvoiceTemplate
{
$this->renderer = $renderer;
return $this;
}
/**
* @return string
*/
public function getCalculator(): string
{
return $this->calculator;
}
/**
* @param string $calculator
* @return InvoiceTemplate
*/
public function setCalculator(string $calculator)
public function setCalculator(string $calculator): InvoiceTemplate
{
$this->calculator = $calculator;
return $this;
}
/**
* @return string
*/
public function getPaymentTerms(): ?string
{
return $this->paymentTerms;
}
/**
* @param string $paymentTerms
* @return InvoiceTemplate
*/
public function setPaymentTerms(?string $paymentTerms)
public function setPaymentTerms(?string $paymentTerms): InvoiceTemplate
{
$this->paymentTerms = $paymentTerms;

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -77,7 +79,7 @@ class Project
private $budget = 0.00;
/**
* @var Activity[]
* @var Activity[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="App\Entity\Activity", mappedBy="project")
*/
@@ -88,54 +90,43 @@ class Project
use ColorTrait;
/**
* @var Timesheet[]
* @var Timesheet[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="App\Entity\Timesheet", mappedBy="project")
*/
private $timesheets;
/**
* @return int
*/
public function getId()
public function __construct()
{
$this->activities = new ArrayCollection();
$this->timesheets = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
/**
* @return Customer
*/
public function getCustomer()
public function getCustomer(): ?Customer
{
return $this->customer;
}
/**
* @param Customer $customer
* @return Project
*/
public function setCustomer($customer)
public function setCustomer(Customer $customer): Project
{
$this->customer = $customer;
return $this;
}
/**
* @param string $name
* @return Project
*/
public function setName($name)
public function setName(string $name): Project
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
@@ -144,26 +135,19 @@ class Project
* @param string $comment
* @return Project
*/
public function setComment($comment)
public function setComment($comment): Project
{
$this->comment = $comment;
return $this;
}
/**
* @return string
*/
public function getComment()
public function getComment(): ?string
{
return $this->comment;
}
/**
* @param bool $visible
* @return Project
*/
public function setVisible($visible)
public function setVisible(bool $visible): Project
{
$this->visible = $visible;
@@ -173,7 +157,7 @@ class Project
/**
* @return bool
*/
public function getVisible()
public function getVisible(): bool
{
return $this->visible;
}
@@ -182,7 +166,7 @@ class Project
* @param float $budget
* @return Project
*/
public function setBudget($budget)
public function setBudget($budget): Project
{
$this->budget = $budget;
@@ -198,39 +182,17 @@ class Project
}
/**
* @param Timesheet[] $timesheets
* @return Project
* @return Collection<Timesheet>
*/
public function setTimesheets($timesheets)
{
$this->timesheets = $timesheets;
return $this;
}
/**
* @return Timesheet[]
*/
public function getTimesheets()
public function getTimesheets(): Collection
{
return $this->timesheets;
}
/**
* @param Activity[] $activities
* @return Project
* @return Collection<Activity>
*/
public function setActivities($activities)
{
$this->activities = $activities;
return $this;
}
/**
* @return Activity[]
*/
public function getActivities()
public function getActivities(): Collection
{
return $this->activities;
}
@@ -247,7 +209,7 @@ class Project
* @param string $orderNumber
* @return Project
*/
public function setOrderNumber($orderNumber)
public function setOrderNumber($orderNumber): Project
{
$this->orderNumber = $orderNumber;

View File

@@ -55,36 +55,23 @@ class Tag
$this->timesheets = new ArrayCollection();
}
/**
* @return int
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
/**
* @param string $tagName
* @return Tag
*/
public function setName($tagName)
public function setName(string $tagName): Tag
{
$this->name = $tagName;
return $this;
}
/**
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
/**
* @param Timesheet $timesheet
*/
public function addTimesheet(Timesheet $timesheet)
{
if ($this->timesheets->contains($timesheet)) {
@@ -95,9 +82,6 @@ class Tag
$timesheet->addTag($this);
}
/**
* @param Timesheet $timesheet
*/
public function removeTimesheet(Timesheet $timesheet)
{
if (!$this->timesheets->contains($timesheet)) {

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -148,11 +149,11 @@ class Timesheet
}
/**
* Get entry id
* Get entry id, returns null for new entities which were not persisted.
*
* @return int
* @return int|null
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
@@ -178,10 +179,7 @@ class Timesheet
$this->localized = true;
}
/**
* @return \DateTime
*/
public function getBegin()
public function getBegin(): ?\DateTime
{
$this->localizeDates();
@@ -192,7 +190,7 @@ class Timesheet
* @param \DateTime $begin
* @return Timesheet
*/
public function setBegin(\DateTime $begin)
public function setBegin(\DateTime $begin): Timesheet
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
@@ -200,10 +198,7 @@ class Timesheet
return $this;
}
/**
* @return \DateTime|null
*/
public function getEnd()
public function getEnd(): ?\DateTime
{
$this->localizeDates();
@@ -214,7 +209,7 @@ class Timesheet
* @param \DateTime $end
* @return Timesheet
*/
public function setEnd(?\DateTime $end)
public function setEnd(?\DateTime $end): Timesheet
{
$this->end = $end;
@@ -232,7 +227,7 @@ class Timesheet
* @param int $duration
* @return Timesheet
*/
public function setDuration($duration)
public function setDuration($duration): Timesheet
{
$this->duration = $duration;
@@ -253,17 +248,14 @@ class Timesheet
* @param User $user
* @return Timesheet
*/
public function setUser(User $user)
public function setUser(User $user): Timesheet
{
$this->user = $user;
return $this;
}
/**
* @return User
*/
public function getUser()
public function getUser(): ?User
{
return $this->user;
}
@@ -272,25 +264,19 @@ class Timesheet
* @param Activity $activity
* @return Timesheet
*/
public function setActivity($activity)
public function setActivity($activity): Timesheet
{
$this->activity = $activity;
return $this;
}
/**
* @return Activity
*/
public function getActivity()
public function getActivity(): ?Activity
{
return $this->activity;
}
/**
* @return Project
*/
public function getProject()
public function getProject(): ?Project
{
return $this->project;
}
@@ -299,7 +285,7 @@ class Timesheet
* @param Project $project
* @return Timesheet
*/
public function setProject(Project $project)
public function setProject(Project $project): Timesheet
{
$this->project = $project;
@@ -310,17 +296,14 @@ class Timesheet
* @param string $description
* @return Timesheet
*/
public function setDescription($description)
public function setDescription($description): Timesheet
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getDescription()
public function getDescription(): ?string
{
return $this->description;
}
@@ -329,7 +312,7 @@ class Timesheet
* @param float $rate
* @return Timesheet
*/
public function setRate($rate)
public function setRate($rate): Timesheet
{
$this->rate = $rate;
@@ -348,7 +331,7 @@ class Timesheet
* @param Tag $tag
* @return Timesheet
*/
public function addTag(Tag $tag)
public function addTag(Tag $tag): Timesheet
{
if ($this->tags->contains($tag)) {
return $this;
@@ -372,9 +355,9 @@ class Timesheet
}
/**
* @return Tag[]|ArrayCollection
* @return Collection<Tag>
*/
public function getTags()
public function getTags(): Collection
{
return $this->tags;
}
@@ -404,7 +387,7 @@ class Timesheet
* @param bool $exported
* @return Timesheet
*/
public function setExported(bool $exported)
public function setExported(bool $exported): Timesheet
{
$this->exported = $exported;
@@ -426,7 +409,7 @@ class Timesheet
* @param string $timezone
* @return Timesheet
*/
public function setTimezone(string $timezone)
public function setTimezone(string $timezone): Timesheet
{
$this->timezone = $timezone;

View File

@@ -105,122 +105,77 @@ class User extends BaseUser implements UserInterface
$this->preferences = new ArrayCollection();
}
/**
* @return int
*/
public function getId()
public function getId(): ?int
{
return $this->id;
}
/**
* @return \DateTime
*/
public function getRegisteredAt()
public function getRegisteredAt(): ?\DateTime
{
return $this->registeredAt;
}
/**
* @param \DateTime $registeredAt
* @return $this
*/
public function setRegisteredAt(\DateTime $registeredAt)
public function setRegisteredAt(\DateTime $registeredAt): User
{
$this->registeredAt = $registeredAt;
return $this;
}
/**
* @param string $alias
* @return $this
*/
public function setAlias($alias)
public function setAlias(?string $alias): User
{
$this->alias = $alias;
return $this;
}
/**
* @return string
*/
public function getAlias()
public function getAlias(): ?string
{
return $this->alias;
}
/**
* @return string
*/
public function getTitle()
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return $this
*/
public function setTitle($title)
public function setTitle(?string $title): User
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getAvatar()
public function getAvatar(): ?string
{
return $this->avatar;
}
/**
* @param string $avatar
* @return $this
*/
public function setAvatar($avatar)
public function setAvatar(?string $avatar): User
{
$this->avatar = $avatar;
return $this;
}
/**
* @return string
*/
public function getApiToken()
public function getApiToken(): ?string
{
return $this->apiToken;
}
/**
* @param string $apiToken
* @return User
*/
public function setApiToken($apiToken)
public function setApiToken(?string $apiToken): User
{
$this->apiToken = $apiToken;
return $this;
}
/**
* @return string
*/
public function getPlainApiToken(): ?string
{
return $this->plainApiToken;
}
/**
* @param string $plainApiToken
* @return User
*/
public function setPlainApiToken(string $plainApiToken)
public function setPlainApiToken(?string $plainApiToken): User
{
$this->plainApiToken = $plainApiToken;
@@ -228,7 +183,7 @@ class User extends BaseUser implements UserInterface
}
/**
* @return UserPreference[]|Collection
* @return Collection<UserPreference>
*/
public function getPreferences(): Collection
{
@@ -236,10 +191,10 @@ class User extends BaseUser implements UserInterface
}
/**
* @param UserPreference[]|Collection<UserPreference> $preferences
* @param iterable<UserPreference> $preferences
* @return User
*/
public function setPreferences($preferences)
public function setPreferences(iterable $preferences): User
{
$this->preferences = new ArrayCollection();
@@ -252,10 +207,29 @@ class User extends BaseUser implements UserInterface
/**
* @param string $name
* @return UserPreference|null
* @param bool|int|string|null $value
*/
public function getPreference(string $name)
public function setPreferenceValue(string $name, $value = null)
{
$pref = $this->getPreference($name);
if (null === $pref) {
$pref = new UserPreference();
$pref->setName($name);
$this->addPreference($pref);
}
$pref->setValue($value);
}
public function getPreference(string $name): ?UserPreference
{
// this code will be triggered, if a currently logged-in user will be deleted and the refreshed from the session
// via one of the UserProvider - e.g. see LdapUserProvider::refreshUser() which calls $user->getPreferenceValue()
if (empty($this->preferences)) {
return null;
}
foreach ($this->preferences as $preference) {
if ($preference->getName() == $name) {
return $preference;
@@ -268,7 +242,7 @@ class User extends BaseUser implements UserInterface
/**
* @return string
*/
public function getLocale()
public function getLocale(): string
{
return $this->getPreferenceValue(UserPreference::LOCALE, User::DEFAULT_LANGUAGE);
}
@@ -292,7 +266,7 @@ class User extends BaseUser implements UserInterface
* @param UserPreference $preference
* @return User
*/
public function addPreference(UserPreference $preference)
public function addPreference(UserPreference $preference): User
{
$this->preferences->add($preference);
$preference->setUser($this);

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type;
use App\Security\RoleService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,14 +20,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class UserRoleType extends AbstractType
{
/**
* @var string[]
* @var RoleService
*/
protected $roles = [];
protected $roles;
/**
* @param string[] $roles
*/
public function __construct(array $roles = [])
public function __construct(RoleService $roles)
{
$this->roles = $roles;
}
@@ -37,13 +35,8 @@ class UserRoleType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$roles = [];
foreach ($this->roles as $key => $value) {
$roles[$key] = $key;
if (is_array($value)) {
foreach ($value as $value2) {
$roles[$value2] = $value2;
}
}
foreach ($this->roles->getAvailableNames() as $name) {
$roles[$name] = $name;
}
$resolver->setDefaults([

View File

@@ -17,9 +17,11 @@ use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -58,6 +60,10 @@ class Kernel extends BaseKernel
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
$extension->addSecurityListenerFactory(new FormLoginLdapFactory());
}
public function registerBundles()

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\Ldap;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class FormLoginLdapFactory implements SecurityFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId)
{
$authProviderId = $this->createAuthProvider($container, $id, $userProviderId);
$listenerId = $this->createListener($container, $id, $config);
return [$authProviderId, $listenerId, $defaultEntryPointId];
}
public function getPosition()
{
return 'pre_auth';
}
public function getKey()
{
return 'kimai_ldap';
}
public function addConfiguration(NodeDefinition $node)
{
}
protected function createAuthProvider(ContainerBuilder $container, $id, $userProviderId)
{
$provider = 'kimai_ldap.security.authentication.provider';
$providerId = $provider . '.' . $id;
$container
->setDefinition($providerId, new ChildDefinition($provider))
->replaceArgument(1, $id)
->replaceArgument(2, new Reference($userProviderId))
;
return $providerId;
}
protected function createListener(ContainerBuilder $container, $id, $config)
{
$listenerId = 'security.authentication.listener.form';
$listener = new ChildDefinition($listenerId);
$listener->replaceArgument(4, $id);
$listener->replaceArgument(5, $config);
$listenerId .= '.' . $id;
$container->setDefinition($listenerId, $listener);
return $listenerId;
}
}

View File

@@ -0,0 +1,131 @@
<?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\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;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapAuthenticationProvider extends UserAuthenticationProvider
{
/**
* @var UserProviderInterface
*/
private $userProvider;
/**
* @var LdapManager
*/
private $ldapManager;
/**
* @var LdapConfiguration
*/
private $config;
public function __construct(UserCheckerInterface $userChecker, $providerKey, UserProviderInterface $userProvider, LdapManager $ldapManager, LdapConfiguration $config, $hideUserNotFoundExceptions = true)
{
parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions);
$this->ldapManager = $ldapManager;
$this->config = $config;
$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();
if ($user instanceof UserInterface) {
return $user;
}
try {
// this will always query the FOSUserBundle 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);
// do not update the user here from LDAP, as we don't know if the user can be authenticated
} catch (UsernameNotFoundException $notFound) {
throw $notFound;
} catch (\Exception $repositoryProblem) {
$e = new AuthenticationServiceException($repositoryProblem->getMessage(), (int) $repositoryProblem->getCode(), $repositoryProblem);
$e->setToken($token);
throw $e;
}
return $user;
}
/**
* The updateUser() call should theoretically happen in retrieveUser() but that would require an additional
* $this->ldapManager->bind($user, $token->getCredentials())
* to check if the user is still valid.
*
* Symfony calls retrieveUser() before checkAuthentication()
* and we should not used ldap->search() before ldap->bind()
*
* @param UserInterface $user
* @param UsernamePasswordToken $token
* @throws LdapDriverException
*/
protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token)
{
$currentUser = $token->getUser();
$presentedPassword = $token->getCredentials();
if ($currentUser instanceof UserInterface) {
if ('' === $presentedPassword) {
throw new BadCredentialsException(
'The password in the token is empty. Check `erase_credentials` in your `security.yaml`'
);
}
if (!$this->ldapManager->bind($currentUser, $presentedPassword)) {
throw new BadCredentialsException('The credentials were changed from another session.');
}
} else {
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (!$this->ldapManager->bind($user, $presentedPassword)) {
throw new BadCredentialsException('The presented password is invalid.');
}
}
if ($user instanceof User && null !== $user->getPreferenceValue('ldap.dn')) {
try {
$this->ldapManager->updateUser($user);
} catch (LdapDriverException $ex) {
throw new BadCredentialsException('Fetching user data/roles failed, probably DN is expired.');
}
}
}
}

129
src/Ldap/LdapDriver.php Normal file
View File

@@ -0,0 +1,129 @@
<?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\Ldap;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Zend\Ldap\Exception\LdapException;
use Zend\Ldap\Ldap;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapDriver
{
/**
* @var Ldap
*/
private $driver;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @param Ldap $driver Initialized Zend::Ldap Object
* @param LoggerInterface $logger optional logger for write debug messages
*/
public function __construct(Ldap $driver, LoggerInterface $logger = null)
{
$this->driver = $driver;
$this->logger = $logger;
}
/**
* @param string $baseDn
* @param string $filter
* @param array $attributes
* @return array
* @throws LdapDriverException
*/
public function search(string $baseDn, string $filter, array $attributes = []): array
{
$attributes = array_unique(array_merge($attributes, ['+', '*']));
$this->logDebug('{action}({base_dn}, {filter}, {attributes})', [
'action' => 'ldap_search',
'base_dn' => $baseDn,
'filter' => $filter,
'attributes' => $attributes,
]);
try {
$this->driver->bind();
$entries = $this->driver->searchEntries($filter, $baseDn, Ldap::SEARCH_SCOPE_SUB, $attributes);
// searchEntries don't return 'count' key as specified by php native function ldap_get_entries()
$entries['count'] = count($entries);
} catch (LdapException $exception) {
$this->zendExceptionHandler($exception);
throw new LdapDriverException('An error occurred with the search operation.');
}
return $entries;
}
public function bind(UserInterface $user, string $password): bool
{
$bindDn = $user->getUsername();
try {
$this->logDebug('{action}({bindDn}, ****)', [
'action' => 'ldap_bind',
'bindDn' => $bindDn,
]);
$bind = $this->driver->bind($bindDn, $password);
return $bind instanceof Ldap;
} catch (LdapException $exception) {
$this->zendExceptionHandler($exception, $password);
}
return false;
}
/**
* Treat a Zend Ldap Exception.
*/
protected function zendExceptionHandler(LdapException $exception, string $password = null): void
{
$sanitizedException = null !== $password ? new SanitizingException($exception, $password) : $exception;
switch ($exception->getCode()) {
// Error level codes
case LdapException::LDAP_SERVER_DOWN:
if ($this->logger) {
$this->logger->error('{exception}', ['exception' => $sanitizedException]);
}
break;
// Other level codes
default:
$this->logDebug('{exception}', ['exception' => $sanitizedException]);
break;
}
}
/**
* Log debug messages if the logger is set.
*
* @param string $message
* @param array $context
*/
private function logDebug(string $message, array $context = []): void
{
if (null === $this->logger) {
return;
}
$this->logger->debug($message, $context);
}
}

View File

@@ -0,0 +1,18 @@
<?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\Ldap;
class LdapDriverException extends \Exception
{
public function __construct($message)
{
parent::__construct($message);
}
}

165
src/Ldap/LdapManager.php Normal file
View File

@@ -0,0 +1,165 @@
<?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\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapManager
{
/**
* @var LdapConfiguration
*/
protected $config;
/**
* @var LdapDriver
*/
protected $driver;
/**
* @var array
*/
protected $params = [];
/**
* @var LdapUserHydrator
*/
protected $hydrator;
public function __construct(LdapDriver $driver, LdapUserHydrator $hydrator, LdapConfiguration $config)
{
$this->params = $config->getUserParameters();
$this->config = $config;
$this->driver = $driver;
$this->hydrator = $hydrator;
}
/**
* Only executed for unknown local users.
*
* @param string $username
* @return User|null
* @throws \Exception
*/
public function findUserByUsername(string $username): ?UserInterface
{
return $this->findUserBy([$this->params['usernameAttribute'] => $username]);
}
/**
* @param array $criteria
* @return User|null
* @throws LdapDriverException
*/
public function findUserBy(array $criteria): ?UserInterface
{
$filter = $this->buildFilter($criteria);
$entries = $this->driver->search($this->params['baseDn'], $filter);
if ($entries['count'] > 1) {
throw new LdapDriverException('This search must only return a single user');
}
if (0 === $entries['count']) {
return null;
}
// do not updateUser() here, as this would happen before bind()
return $this->hydrator->hydrate($entries[0]);
}
protected function buildFilter(array $criteria, string $condition = '&'): string
{
$filters = [];
$filters[] = $this->params['filter'];
foreach ($criteria as $key => $value) {
$value = ldap_escape($value, '', LDAP_ESCAPE_FILTER);
$filters[] = sprintf('(%s=%s)', $key, $value);
}
return sprintf('(%s%s)', $condition, implode($filters));
}
public function bind(UserInterface $user, string $password): bool
{
return $this->driver->bind($user, $password);
}
/**
* This method does all the heavy lifting:
* - searching for latest 'dn'
* - syncing user attributes
* - syncing roles
*
* @param User $user
* @throws LdapDriverException
*/
public function updateUser(User $user)
{
$baseDn = $user->getPreferenceValue('ldap.dn');
$filter = '(objectClass=*)';
if (null === $baseDn) {
throw new LdapDriverException('This account is not a registered LDAP user');
}
// always look up the users current DN first, as the cached DN might have been renamed in LDAP
$userFresh = $this->findUserByUsername($user->getUsername());
if (null === $userFresh || null === ($baseDn = $userFresh->getPreferenceValue('ldap.dn'))) {
throw new LdapDriverException(sprintf('Failed fetching user DN for %s', $user->getUsername()));
}
$user->setPreferenceValue('ldap.dn', $baseDn);
$entries = $this->driver->search($baseDn, $filter);
if ($entries['count'] > 1) {
throw new LdapDriverException('This search must only return a single user');
}
if (0 === $entries['count']) {
return;
}
$this->hydrator->hydrateUser($user, $entries[0]);
$roleParameter = $this->config->getRoleParameters();
if (null === $roleParameter['baseDn']) {
return;
}
$param = $roleParameter['usernameAttribute'];
if (!isset($entries[0][$param]) && $param !== 'dn') {
$param = 'dn';
}
$roleValue = $entries[0][$param];
if (is_array($roleValue)) {
$roleValue = $roleValue[0];
}
$roles = $this->getRoles($roleValue, $roleParameter);
if (!empty($roles)) {
$this->hydrator->hydrateRoles($user, $roles);
}
}
protected function getRoles(string $dn, array $roleParameter): array
{
$filter = $roleParameter['filter'] ?? '';
return $this->driver->search(
$roleParameter['baseDn'],
sprintf('(&%s(%s=%s))', $filter, $roleParameter['userDnAttribute'], $dn),
[$roleParameter['nameAttribute']]
);
}
}

View File

@@ -0,0 +1,144 @@
<?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\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use App\Security\RoleService;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class LdapUserHydrator
{
/**
* @var LdapConfiguration
*/
private $config;
/**
* @var RoleService
*/
private $roles;
public function __construct(LdapConfiguration $config, RoleService $roles)
{
$this->config = $config;
$this->roles = $roles;
}
protected function createUser(): User
{
$user = new User();
$user->setEnabled(true);
return $user;
}
public function hydrate(array $ldapEntry): User
{
$user = $this->createUser();
$this->hydrateUser($user, $ldapEntry);
return $user;
}
public function hydrateUser(User $user, array $ldapEntry)
{
$userParams = $this->config->getUserParameters();
$attributeMap = $userParams['attributes'];
$attributeMap = array_merge(
[
['ldap_attr' => $userParams['usernameAttribute'], 'user_method' => 'setUsername'],
],
$attributeMap
);
$this->hydrateUserWithAttributesMap($user, $ldapEntry, $attributeMap);
if (null === $user->getEmail()) {
$user->setEmail($user->getUsername());
}
// prevent that users will define a password for the internal account
$user->setPassword('');
$user->setPreferenceValue('ldap.dn', $ldapEntry['dn']);
}
/**
* @param User $user
* @param array $entries
*/
public function hydrateRoles(User $user, array $entries)
{
$roleParams = $this->config->getRoleParameters();
$allowedRoles = $this->roles->getAvailableNames();
$groupNameMapping = $roleParams['groups'];
$roleNameAttr = $roleParams['nameAttribute'];
$roles = [];
for ($i = 0; $i < $entries['count']; $i++) {
$roleName = $entries[$i][$roleNameAttr][0];
$mapped = false;
foreach ($groupNameMapping as $attr) {
if ($roleName === $attr['ldap_value']) {
$roleName = $attr['role'];
$mapped = true;
}
}
if (!$mapped) {
$roleName = sprintf('ROLE_%s', self::slugify($roleName));
}
if (!in_array($roleName, $allowedRoles)) {
continue;
}
$roles[] = $roleName;
}
$user->setRoles($roles);
}
private static function slugify(string $role): string
{
$role = preg_replace('/\W+/', '_', $role);
$role = trim($role, '_');
$role = strtoupper($role);
return $role;
}
protected function hydrateUserWithAttributesMap(UserInterface $user, array $ldapUserAttributes, array $attributeMap)
{
/** @var array $attr */
foreach ($attributeMap as $attr) {
if (!array_key_exists($attr['ldap_attr'], $ldapUserAttributes)) {
continue;
}
$ldapValue = $ldapUserAttributes[$attr['ldap_attr']];
if (array_key_exists('count', $ldapValue)) {
unset($ldapValue['count']);
}
if (1 === count($ldapValue)) {
$value = array_shift($ldapValue);
} else {
$value = $ldapValue;
}
$user->{$attr['user_method']}($value);
}
}
}

View File

@@ -0,0 +1,120 @@
<?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\Ldap;
use App\Configuration\LdapConfiguration;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
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
*/
class LdapUserProvider implements UserProviderInterface
{
/**
* @var bool
*/
protected $activated = false;
/**
* @var LdapManager
*/
protected $ldapManager;
/**
* @var LoggerInterface|null
*/
protected $logger;
public function __construct(LdapManager $ldapManager, LdapConfiguration $config, LoggerInterface $logger = null)
{
$this->ldapManager = $ldapManager;
$this->logger = $logger;
$this->activated = $config->isActivated();
}
public function loadUserByUsername($username)
{
// this method is called at least for unknown user, no matter what supportsClass() returns,
// so we have to check if LDAP is activated here as well
if (!$this->activated) {
$ex = new UsernameNotFoundException(sprintf('LDAP is deactivated, user "%s" not searched', $username));
$ex->setUsername($username);
throw $ex;
}
$user = $this->ldapManager->findUserByUsername($username);
if (empty($user)) {
$this->logInfo('User {username} {result} on LDAP', [
'action' => 'loadUserByUsername',
'username' => $username,
'result' => 'not found',
]);
$ex = new UsernameNotFoundException(sprintf('User "%s" not found', $username));
$ex->setUsername($username);
throw $ex;
}
$this->logInfo('User {username} {result} on LDAP', [
'action' => 'loadUserByUsername',
'username' => $username,
'result' => 'found',
]);
return $user;
}
public function refreshUser(UserInterface $user)
{
if (!($user instanceof User) || !$this->supportsClass(get_class($user))) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
}
if (null === $user->getPreferenceValue('ldap.dn')) {
throw new UnsupportedUserException(sprintf('Account "%s" is not a registered LDAP user.', $user->getUsername()));
}
try {
$this->ldapManager->updateUser($user);
} catch (LdapDriverException $ex) {
throw new UnsupportedUserException(sprintf('Failed to refresh user "%s", probably DN is expired.', $user->getUsername()));
}
return $user;
}
public function supportsClass($class)
{
if (!$this->activated) {
return false;
}
return $class === User::class || $class === 'App\Entity\User';
}
/**
* Log a message into the logger if this exists.
*/
private function logInfo(string $message, array $context = []): void
{
if (!$this->logger) {
return;
}
$this->logger->info($message, $context);
}
}

View File

@@ -0,0 +1,40 @@
<?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\Ldap;
/**
* Inspired by https://github.com/Maks3w/FR3DLdapBundle @ MIT License
*/
class SanitizingException extends \Exception
{
protected $actualException;
protected $secret;
public function __construct(\Exception $actualException, $secret)
{
parent::__construct(
$this->stripSecret($actualException->getMessage(), $secret),
$actualException->getCode()
);
$this->actualException = $actualException;
$this->secret = $secret;
}
protected function stripSecret(string $message, string $secret)
{
return str_replace($secret, '****', $message);
}
public function __toString()
{
return $this->stripSecret($this->actualException->__toString(), $this->secret);
}
}

29
src/Ldap/ZendLdap.php Normal file
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\Ldap;
use App\Configuration\LdapConfiguration;
use Zend\Ldap\Ldap;
/**
* Overwritten to prevent errors in case:
* LDAP is deactivated and LDAP extension is not loaded
*/
class ZendLdap extends Ldap
{
public function __construct(LdapConfiguration $config)
{
if (!$config->isActivated()) {
return;
}
parent::__construct($config->getConnectionParameters());
}
}

View File

@@ -23,7 +23,7 @@ use App\Repository\Query\InvoiceQuery;
class InvoiceModel
{
/**
* @var Customer
* @var Customer|null
*/
protected $customer;
@@ -95,36 +95,26 @@ class InvoiceModel
* @param Timesheet[] $entries
* @return InvoiceModel
*/
public function setEntries(array $entries)
public function setEntries(array $entries): InvoiceModel
{
$this->entries = $entries;
return $this;
}
/**
* @return InvoiceTemplate
*/
public function getTemplate(): ?InvoiceTemplate
{
return $this->template;
}
/**
* @param InvoiceTemplate $template
* @return InvoiceModel
*/
public function setTemplate(InvoiceTemplate $template)
public function setTemplate(InvoiceTemplate $template): InvoiceModel
{
$this->template = $template;
return $this;
}
/**
* @return Customer
*/
public function getCustomer()
public function getCustomer(): ?Customer
{
return $this->customer;
}
@@ -133,16 +123,13 @@ class InvoiceModel
* @param Customer $customer
* @return InvoiceModel
*/
public function setCustomer($customer)
public function setCustomer($customer): InvoiceModel
{
$this->customer = $customer;
return $this;
}
/**
* @return \DateTime
*/
public function getDueDate(): ?\DateTime
{
if (null === $this->getTemplate()) {
@@ -160,11 +147,7 @@ class InvoiceModel
return $this->invoiceDate;
}
/**
* @param NumberGeneratorInterface $generator
* @return InvoiceModel
*/
public function setNumberGenerator(NumberGeneratorInterface $generator)
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
{
$this->generator = $generator;
$this->generator->setModel($this);
@@ -172,19 +155,12 @@ class InvoiceModel
return $this;
}
/**
* @return NumberGeneratorInterface
*/
public function getNumberGenerator(): ?NumberGeneratorInterface
{
return $this->generator;
}
/**
* @param CalculatorInterface $calculator
* @return InvoiceModel
*/
public function setCalculator(CalculatorInterface $calculator)
public function setCalculator(CalculatorInterface $calculator): InvoiceModel
{
$this->calculator = $calculator;
$this->calculator->setModel($this);
@@ -192,9 +168,6 @@ class InvoiceModel
return $this;
}
/**
* @return CalculatorInterface
*/
public function getCalculator(): ?CalculatorInterface
{
return $this->calculator;

View File

@@ -33,7 +33,7 @@ class TimesheetStatistic
*/
protected $amountTotal = 0;
/**
* @var \DateTime
* @var \DateTime|null
*/
protected $firstEntry;
/**
@@ -41,10 +41,7 @@ class TimesheetStatistic
*/
protected $recordsTotal = 0;
/**
* @return int
*/
public function getDurationThisMonth()
public function getDurationThisMonth(): int
{
return $this->durationThisMonth;
}
@@ -57,10 +54,7 @@ class TimesheetStatistic
$this->durationThisMonth = (int) $durationThisMonth;
}
/**
* @return int
*/
public function getAmountTotal()
public function getAmountTotal(): int
{
return $this->amountTotal;
}
@@ -73,10 +67,7 @@ class TimesheetStatistic
$this->amountTotal = (int) $amountTotal;
}
/**
* @return int
*/
public function getDurationTotal()
public function getDurationTotal(): int
{
return $this->durationTotal;
}
@@ -89,10 +80,7 @@ class TimesheetStatistic
$this->durationTotal = (int) $durationTotal;
}
/**
* @return int
*/
public function getAmountThisMonth()
public function getAmountThisMonth(): int
{
return $this->amountThisMonth;
}
@@ -105,10 +93,7 @@ class TimesheetStatistic
$this->amountThisMonth = (int) $amountThisMonth;
}
/**
* @return DateTime
*/
public function getFirstEntry()
public function getFirstEntry(): ?\DateTime
{
return $this->firstEntry;
}

View File

@@ -17,7 +17,7 @@ use App\Entity\Project;
class ActivityQuery extends ProjectQuery
{
/**
* @var Project|int
* @var Project|int|null
*/
protected $project;
/**
@@ -37,11 +37,7 @@ class ActivityQuery extends ProjectQuery
return $this->orderGlobalsFirst;
}
/**
* @param bool $orderGlobalsFirst
* @return ActivityQuery
*/
public function setOrderGlobalsFirst(bool $orderGlobalsFirst)
public function setOrderGlobalsFirst(bool $orderGlobalsFirst): ActivityQuery
{
$this->orderGlobalsFirst = $orderGlobalsFirst;
@@ -60,15 +56,15 @@ class ActivityQuery extends ProjectQuery
* @param bool $globalsOnly
* @return ActivityQuery
*/
public function setGlobalsOnly($globalsOnly)
public function setGlobalsOnly($globalsOnly): ActivityQuery
{
$this->globalsOnly = $globalsOnly;
$this->globalsOnly = (bool) $globalsOnly;
return $this;
}
/**
* @return Project|int
* @return Project|int|null
*/
public function getProject()
{
@@ -76,10 +72,10 @@ class ActivityQuery extends ProjectQuery
}
/**
* @param Project|int $project
* @return $this
* @param Project|int|null $project
* @return ActivityQuery
*/
public function setProject($project = null)
public function setProject($project = null): ActivityQuery
{
$this->project = $project;

View File

@@ -25,7 +25,7 @@ class BaseQuery
public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
/**
* @var object
* @var object|null
*/
protected $hiddenEntity;
/**
@@ -158,7 +158,7 @@ class BaseQuery
}
/**
* @return object
* @return object|null
*/
public function getHiddenEntity()
{
@@ -166,7 +166,7 @@ class BaseQuery
}
/**
* @param object|string $hiddenEntity
* @param object|string|null $hiddenEntity
* @return BaseQuery
*/
public function setHiddenEntity($hiddenEntity)

View File

@@ -17,7 +17,7 @@ use App\Entity\Customer;
class ProjectQuery extends VisibilityQuery
{
/**
* @var Customer|int
* @var Customer|int|null
*/
protected $customer;
@@ -46,7 +46,7 @@ class ProjectQuery extends VisibilityQuery
}
/**
* @return Customer|int
* @return Customer|int|null
*/
public function getCustomer()
{
@@ -54,7 +54,7 @@ class ProjectQuery extends VisibilityQuery
}
/**
* @param Customer|int $customer
* @param Customer|int|null $customer
* @return $this
*/
public function setCustomer($customer = null)

View File

@@ -36,11 +36,11 @@ class TimesheetQuery extends ActivityQuery
*/
protected $orderBy = 'begin';
/**
* @var User
* @var User|null
*/
protected $user;
/**
* @var Activity
* @var Activity|null
*/
protected $activity;
/**
@@ -66,7 +66,7 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @return User
* @return User|null
*/
public function getUser()
{
@@ -74,7 +74,7 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @param User|int $user
* @param User|int|null $user
* @return TimesheetQuery
*/
public function setUser($user = null)
@@ -87,7 +87,7 @@ class TimesheetQuery extends ActivityQuery
/**
* Activity overwrites: setProject() and setCustomer()
*
* @return Activity
* @return Activity|null
*/
public function getActivity()
{
@@ -95,7 +95,7 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @param Activity|int $activity
* @param Activity|int|null $activity
* @return TimesheetQuery
*/
public function setActivity($activity = null)

View File

@@ -15,12 +15,12 @@ namespace App\Repository\Query;
class UserQuery extends VisibilityQuery
{
/**
* @var string
* @var string|null
*/
protected $role;
/**
* @return string
* @return string|null
*/
public function getRole()
{

View File

@@ -274,6 +274,7 @@ class TimesheetRepository extends AbstractRepository
->join('t.activity', 'a')
->join('t.project', 'p')
->join('p.customer', 'c')
->leftJoin('t.tags', 'tags')
->where($qb->expr()->isNotNull('t.begin'))
->andWhere($qb->expr()->isNull('t.end'))
->orderBy('t.begin', 'DESC');
@@ -438,11 +439,12 @@ class TimesheetRepository extends AbstractRepository
$ids = array_column($results, 'maxid');
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t', 'a', 'p', 'c')
$qb->select('t', 'a', 'p', 'c', 'tags')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('t.project', 'p')
->join('p.customer', 'c')
->leftJoin('t.tags', 'tags')
->andWhere($qb->expr()->in('t.id', $ids))
->orderBy('t.end', 'DESC')
;

View File

@@ -20,7 +20,6 @@ class AclDecisionManager
protected $decisionManager;
/**
* AbstractVoter constructor.
* @param AccessDecisionManagerInterface $decisionManager
*/
public function __construct(AccessDecisionManagerInterface $decisionManager)
@@ -40,22 +39,4 @@ class AclDecisionManager
return false;
}
/**
* @param TokenInterface $token
* @param string|array $roles
* @return bool
*/
public function hasRole(TokenInterface $token, $roles)
{
if (!is_array($roles)) {
$roles = [$roles];
}
if ($this->decisionManager->decide($token, $roles)) {
return true;
}
return false;
}
}

View File

@@ -49,11 +49,6 @@ class RolePermissionManager
return array_keys($this->permissions);
}
public function roleHasPermission(string $role): bool
{
return isset($this->permissions[$role]);
}
public function getPermissions(): array
{
return $this->knownPermissions;

View File

@@ -0,0 +1,38 @@
<?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;
class RoleService
{
/**
* @var array
*/
protected $roles;
public function __construct(array $roles)
{
$this->roles = $roles;
}
public function getAvailableNames(): array
{
$roles = [];
foreach ($this->roles as $key => $value) {
$roles[] = $key;
if (is_array($value)) {
foreach ($value as $name) {
$roles[] = $name;
}
}
}
return array_values(array_unique($roles));
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\LockedException;
use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -26,6 +26,15 @@ class UserChecker implements UserCheckerInterface
*/
public function checkPreAuth(UserInterface $user)
{
if (!($user instanceof User)) {
return;
}
if (!$user->isEnabled()) {
$ex = new DisabledException('User account is disabled.');
$ex->setUser($user);
throw $ex;
}
}
/**
@@ -34,13 +43,14 @@ class UserChecker implements UserCheckerInterface
*/
public function checkPostAuth(UserInterface $user)
{
if (!$user instanceof User) {
if (!($user instanceof User)) {
return;
}
// user account is not enabled, the user may be notified
if (!$user->isEnabled()) {
throw new LockedException();
$ex = new DisabledException('User account is disabled.');
$ex->setUser($user);
throw $ex;
}
}
}

View File

@@ -48,16 +48,6 @@ abstract class AbstractVoter extends Voter
return $this->decisionManager->isFullyAuthenticated($token);
}
/**
* @param string $role
* @param TokenInterface $token
* @return bool
*/
protected function hasRole($role, TokenInterface $token)
{
return $this->decisionManager->hasRole($token, [$role]);
}
/**
* @param string $role
* @param string $permission

View File

@@ -83,7 +83,6 @@ class UserVoter extends AbstractVoter
// used in templates and ProfileController
case self::VIEW:
case self::EDIT:
case self::PASSWORD:
case self::PREFERENCES:
// always allow the user to edit these own settings
if ($subject->getId() === $user->getId()) {
@@ -91,6 +90,7 @@ class UserVoter extends AbstractVoter
}
// no break on purpose
case self::PASSWORD:
case self::API_TOKEN:
case self::ROLES:
case self::HOURLY_RATE: