Release 2.54 (#5896)
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
old_api_tokens:
|
||||
policy: 'fixed_window'
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
lock_factory: null
|
||||
session_prediction:
|
||||
policy: 'fixed_window'
|
||||
limit: 250
|
||||
|
||||
@@ -71,6 +71,7 @@ final class ActionsController extends BaseApiController
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('view', 'timesheet')]
|
||||
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
|
||||
@@ -90,6 +91,7 @@ final class ActionsController extends BaseApiController
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('view', 'activity')]
|
||||
public function getActivityActions(Activity $activity, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
|
||||
@@ -109,6 +111,7 @@ final class ActionsController extends BaseApiController
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function getProjectActions(Project $project, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
|
||||
@@ -128,6 +131,7 @@ final class ActionsController extends BaseApiController
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);
|
||||
|
||||
@@ -36,8 +36,8 @@ final class ApiRequestMatcher implements RequestMatcherInterface
|
||||
}
|
||||
|
||||
// let's use this firewall if the deprecated username & token combination is available
|
||||
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
|
||||
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && // @phpstan-ignore classConstant.deprecatedClass
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
|
||||
return true;
|
||||
}
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,9 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
*/
|
||||
final class ApiTokenMigratingListener implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)
|
||||
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
*/
|
||||
final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
{
|
||||
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)
|
||||
|
||||
@@ -13,17 +13,24 @@ use App\Entity\User;
|
||||
use App\Repository\ApiUserRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
*/
|
||||
final class TokenAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public const HEADER_USERNAME = 'X-AUTH-USER';
|
||||
@@ -31,7 +38,9 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiUserRepository $userProvider,
|
||||
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
|
||||
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
|
||||
private readonly RateLimiterFactory $oldApiTokensLimiter,
|
||||
private readonly RequestStack $requestStack,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -44,8 +53,6 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
}
|
||||
|
||||
if ($request->headers->has(self::HEADER_USERNAME) && $request->headers->has(self::HEADER_TOKEN)) {
|
||||
@trigger_error('You are using deprecated API access, please upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -77,10 +84,12 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
|
||||
$checkCredentials = function (?string $presentedPassword, User $user) {
|
||||
if ('' === $presentedPassword) {
|
||||
$this->rateLimitInvalidLogin();
|
||||
throw new BadCredentialsException('The presented password cannot be empty.');
|
||||
}
|
||||
|
||||
if (null === $user->getApiToken()) {
|
||||
$this->rateLimitInvalidLogin();
|
||||
throw new BadCredentialsException('The user has no activated API account.');
|
||||
}
|
||||
|
||||
@@ -88,11 +97,17 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->rateLimitInvalidLogin();
|
||||
throw new BadCredentialsException('The presented password is invalid.');
|
||||
};
|
||||
|
||||
// users should really move away from this auth endpoint
|
||||
// see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
@trigger_error('Using deprecated API passwords, upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
|
||||
usleep(mt_rand(200000, 500000));
|
||||
|
||||
$passport = new Passport(
|
||||
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
|
||||
new UserBadge($credentials['username'], [$this, 'loadUserByIdentifier']),
|
||||
new CustomCredentials($checkCredentials, $credentials['password'])
|
||||
);
|
||||
|
||||
@@ -101,6 +116,30 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
return $passport;
|
||||
}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): ?UserInterface
|
||||
{
|
||||
$user = $this->userProvider->loadUserByIdentifier($identifier);
|
||||
|
||||
if ($user === null) {
|
||||
// we could use usleep(500000); to slow down potential attacks, but using a hashing makes timing attacks more difficult
|
||||
$this->passwordHasherFactory->getPasswordHasher(User::class)->verify('$2y$13$vwn35gUbbivoS75wcByBzObCNjX4vwkBihbdXQuK23HzK1R6J5WKW', uniqid());
|
||||
|
||||
$this->rateLimitInvalidLogin();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function rateLimitInvalidLogin(): void
|
||||
{
|
||||
$limiter = $this->oldApiTokensLimiter->create($this->requestStack->getMainRequest()?->getClientIp());
|
||||
$limit = $limiter->consume();
|
||||
|
||||
if (false === $limit->isAccepted()) {
|
||||
throw new BadRequestHttpException('Too many API requests with invalid username. Possible attack?');
|
||||
}
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -147,7 +147,6 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
|
||||
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$userSuperAdmin->setTitle('Super Administrator');
|
||||
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
|
||||
$userSuperAdmin->setEnabled(true);
|
||||
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
|
||||
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.53.0';
|
||||
public const VERSION = '2.54.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25300;
|
||||
public const VERSION_ID = 25400;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -60,6 +60,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
* Name of this activity
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
@@ -117,6 +118,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
|
||||
private ?string $invoiceText = null;
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\Length(max: 10)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -45,6 +45,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
@@ -52,6 +53,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Exporter\Expose(label: 'name')]
|
||||
private ?string $name = null;
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
@@ -30,6 +31,7 @@ trait MetaTableTypeTrait
|
||||
* Name of the meta (custom) field
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
#[Serializer\Expose]
|
||||
|
||||
@@ -63,6 +63,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
* Project name
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 150)]
|
||||
#[Serializer\Expose]
|
||||
@@ -170,6 +171,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private bool $globalActivities = true;
|
||||
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\Length(max: 10)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TagRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
@@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
class Tag
|
||||
{
|
||||
/**
|
||||
* Internal Tag ID
|
||||
* Tag ID
|
||||
*/
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
@@ -35,9 +36,10 @@ class Tag
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $id = null;
|
||||
/**
|
||||
* The tag name
|
||||
* Tag name cannot contain the character: " < > = ,
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
|
||||
@@ -52,10 +54,6 @@ class Tag
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -39,6 +39,7 @@ class Team
|
||||
* Team name
|
||||
*/
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100)]
|
||||
#[Serializer\Expose]
|
||||
|
||||
@@ -81,7 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
|
||||
#[Assert\Length(max: 60)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'alias')]
|
||||
@@ -97,15 +97,16 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'title')]
|
||||
private ?string $title = null;
|
||||
/**
|
||||
* URL to the user avatar, will be auto-generated if empty
|
||||
* URL to the user avatar
|
||||
*/
|
||||
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Url]
|
||||
#[Assert\Length(max: 255, groups: ['Profile'])]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
@@ -164,7 +165,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $username = null;
|
||||
@@ -176,6 +177,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $email = null;
|
||||
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\Length(max: 30)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -61,6 +62,7 @@ class UserPreference
|
||||
#[Assert\NotNull]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Constraints\NoSpecialCharacters]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(min: 2, max: 50)]
|
||||
#[Serializer\Expose]
|
||||
|
||||
@@ -11,10 +11,14 @@ namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
|
||||
*/
|
||||
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private readonly UserRepository $userRepository)
|
||||
@@ -23,7 +27,11 @@ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterfac
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): ?UserInterface
|
||||
{
|
||||
return $this->userRepository->loadUserByIdentifier($identifier);
|
||||
try {
|
||||
return $this->userRepository->loadUserByIdentifier($identifier);
|
||||
} catch (UserNotFoundException $ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void
|
||||
|
||||
@@ -29,20 +29,30 @@ final class ContractExtensions extends AbstractExtension
|
||||
return [
|
||||
/* @var array{user: User, date: \DateTimeInterface} $values */
|
||||
new TwigTest('work_day', function (array $values): bool {
|
||||
$user = $values['user'];
|
||||
if ($user->getId() === null) {
|
||||
return false;
|
||||
// TODO remove me in 3.0, deprecate with 2.55
|
||||
if (!\array_key_exists('user', $values) || !\array_key_exists('date', $values)) {
|
||||
throw new \Exception('Missing variable "user" or "date" to check for "is work_day');
|
||||
}
|
||||
|
||||
$id = 'user_' . $user->getId();
|
||||
if (!\array_key_exists($id, $this->calculators)) {
|
||||
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
|
||||
}
|
||||
|
||||
$date = $values['date'];
|
||||
|
||||
return $this->calculators[$id]->isWorkDay($date);
|
||||
return $this->isWorkingDay($values['date'], $values['user']);
|
||||
}),
|
||||
new TwigTest('working_day', function (\DateTimeInterface $date, User $user): bool {
|
||||
return $this->isWorkingDay($date, $user);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function isWorkingDay(\DateTimeInterface $date, User $user): bool
|
||||
{
|
||||
if ($user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$id = 'user_' . $user->getId();
|
||||
if (!\array_key_exists($id, $this->calculators)) {
|
||||
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
|
||||
}
|
||||
|
||||
return $this->calculators[$id]->isWorkDay($date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class NoHtmlSpecialCharactersValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof NoHtmlSpecialCharacters)) {
|
||||
throw new UnexpectedTypeException($constraint, NoHtmlSpecialCharacters::class);
|
||||
}
|
||||
|
||||
if (!\is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_contains($value, '<')
|
||||
|| str_contains($value, '>')
|
||||
|| str_contains($value, '"')
|
||||
// there are many family names that use the ' (like O'Hara), so we cannot forbid them
|
||||
) {
|
||||
$this->context->buildViolation(NoHtmlSpecialCharacters::getErrorName(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
|
||||
->setTranslationDomain('validators')
|
||||
->setParameter('{{ chars }}', '< " >')
|
||||
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace App\Validator\Constraints;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class NoHtmlSpecialCharacters extends Constraint
|
||||
final class NoSpecialCharacters extends Constraint
|
||||
{
|
||||
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
|
||||
|
||||
@@ -20,8 +20,32 @@ final class NoHtmlSpecialCharacters extends Constraint
|
||||
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
public array $characters = [
|
||||
'<', // XSS
|
||||
'>', // XSS
|
||||
'"', // XSS
|
||||
'=', // DDE
|
||||
];
|
||||
|
||||
public string $message = 'These characters are not allowed: {{ chars }}';
|
||||
|
||||
/**
|
||||
* @param string[]|null $character
|
||||
*/
|
||||
public function __construct(
|
||||
mixed $options = null,
|
||||
?string $message = null,
|
||||
?array $character = null,
|
||||
?array $groups = null,
|
||||
mixed $payload = null
|
||||
)
|
||||
{
|
||||
parent::__construct($options, $groups, $payload);
|
||||
$this->message = $message ?? $this->message;
|
||||
$this->characters = $character ?? $this->characters;
|
||||
}
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::PROPERTY_CONSTRAINT;
|
||||
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal file
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal 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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class NoSpecialCharactersValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof NoSpecialCharacters)) {
|
||||
throw new UnexpectedTypeException($constraint, NoSpecialCharacters::class);
|
||||
}
|
||||
|
||||
if (!\is_string($value) || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$found = [];
|
||||
foreach ($constraint->characters as $character) {
|
||||
if (str_contains($value, $character)) {
|
||||
$found[] = $character;
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($found) > 0) {
|
||||
$this->context->buildViolation(NoSpecialCharacters::getErrorName(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
|
||||
->setTranslationDomain('validators')
|
||||
->setParameter('{{ chars }}', implode(' ', $constraint->characters))
|
||||
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ invoice['invoice.language'] }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
|
||||
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
|
||||
<style>
|
||||
{{ encore_entry_css_source('invoice-pdf')|raw }}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ invoice['invoice.language'] }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
|
||||
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
|
||||
<style>
|
||||
{{ encore_entry_css_source('invoice')|raw }}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ invoice['invoice.language'] }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
|
||||
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
|
||||
<style>
|
||||
{{ encore_entry_css_source('invoice-pdf')|raw }}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
{% block form_body %}
|
||||
{{ form_start(form) }}
|
||||
<div class="card-body {% block form_body_class %}{% endblock %}">
|
||||
{{ form_errors(form) }}
|
||||
{% block form_pre_content %}{% endblock %}
|
||||
{% block form_content %}
|
||||
{{ form_widget(form) }}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
{% form_theme form 'form/horizontal.html.twig' %}
|
||||
|
||||
{{ form_errors(form) }}
|
||||
{{ form_row(form._token) }}
|
||||
|
||||
<fieldset class="form-fieldset form-fieldset-light">
|
||||
|
||||
@@ -16,8 +16,11 @@ use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
@@ -35,7 +38,12 @@ class TokenAuthenticatorTest extends TestCase
|
||||
$passwordHasher->method('verify')->willReturn($verify);
|
||||
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher);
|
||||
|
||||
return new TokenAuthenticator($userProvider, $passwordHasherFactory);
|
||||
return new TokenAuthenticator(
|
||||
$userProvider,
|
||||
$passwordHasherFactory,
|
||||
new RateLimiterFactory(['id' => 'foo', 'policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 minute'], new InMemoryStorage()),
|
||||
new RequestStack(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupports(): void
|
||||
|
||||
@@ -29,7 +29,7 @@ class ContractExtensionsTest extends TestCase
|
||||
|
||||
public function testDefinedMethods(): void
|
||||
{
|
||||
self::assertCount(1, $this->getSut()->getTests());
|
||||
self::assertCount(2, $this->getSut()->getTests());
|
||||
self::assertCount(0, $this->getSut()->getFilters());
|
||||
self::assertCount(0, $this->getSut()->getFunctions());
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class ContractExtensionsTest extends TestCase
|
||||
{
|
||||
$filters = [
|
||||
'work_day',
|
||||
'working_day',
|
||||
];
|
||||
$i = 0;
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Validator\Constraints\NoHtmlSpecialCharacters;
|
||||
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator;
|
||||
use App\Validator\Constraints\NoSpecialCharacters;
|
||||
use App\Validator\Constraints\NoSpecialCharactersValidator;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
@@ -18,15 +18,15 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @extends ConstraintValidatorTestCase<NoHtmlSpecialCharactersValidator>
|
||||
* @extends ConstraintValidatorTestCase<NoSpecialCharactersValidator>
|
||||
*/
|
||||
#[CoversClass(NoHtmlSpecialCharacters::class)]
|
||||
#[CoversClass(NoHtmlSpecialCharactersValidator::class)]
|
||||
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
#[CoversClass(NoSpecialCharacters::class)]
|
||||
#[CoversClass(NoSpecialCharactersValidator::class)]
|
||||
class NoSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator(): NoHtmlSpecialCharactersValidator
|
||||
protected function createValidator(): NoSpecialCharactersValidator
|
||||
{
|
||||
return new NoHtmlSpecialCharactersValidator();
|
||||
return new NoSpecialCharactersValidator();
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid(): void
|
||||
@@ -38,7 +38,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
public function testGetTargets(): void
|
||||
{
|
||||
$constraint = new NoHtmlSpecialCharacters();
|
||||
$constraint = new NoSpecialCharacters();
|
||||
self::assertEquals('property', $constraint->getTargets());
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
return [
|
||||
[''],
|
||||
[null],
|
||||
['asdf-.,123!§$%&/()=?`4567\'890ß'],
|
||||
['asdf-.,123!§$%&/()?`4567\'890ß'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->validator = $this->createValidator();
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$this->validator->validate($data, new NoHtmlSpecialCharacters());
|
||||
$this->validator->validate($data, new NoSpecialCharacters());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
['Test" onclick="alert(1)"'],
|
||||
['Test><a href=#>Foo</a>'],
|
||||
['Test" broken string'],
|
||||
['I am not = allowed'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,11 +78,11 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->validator = $this->createValidator();
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$this->validator->validate($data, new NoHtmlSpecialCharacters());
|
||||
$this->validator->validate($data, new NoSpecialCharacters());
|
||||
|
||||
$this->buildViolation('These characters are not allowed: {{ chars }}')
|
||||
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->setParameter('{{ chars }}', '< " >')
|
||||
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->setParameter('{{ chars }}', '< > " =')
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,26 @@ parameters:
|
||||
-
|
||||
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
|
||||
|
||||
-
|
||||
identifier: classConstant.deprecatedClass
|
||||
count: 2
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
identifier: new.deprecatedClass
|
||||
count: 1
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
identifier: return.deprecatedClass
|
||||
count: 1
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
identifier: method.deprecatedClass
|
||||
count: 13
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
||||
Reference in New Issue
Block a user