diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index 4daf72c1f..151270869 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -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 diff --git a/src/API/ActionsController.php b/src/API/ActionsController.php index 3000f91e6..b9dcf6802 100644 --- a/src/API/ActionsController.php +++ b/src/API/ActionsController.php @@ -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); diff --git a/src/API/Authentication/ApiRequestMatcher.php b/src/API/Authentication/ApiRequestMatcher.php index 5febb943e..142a477fd 100644 --- a/src/API/Authentication/ApiRequestMatcher.php +++ b/src/API/Authentication/ApiRequestMatcher.php @@ -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; } // ------------------------------------------------------------------------------------ diff --git a/src/API/Authentication/ApiTokenMigratingListener.php b/src/API/Authentication/ApiTokenMigratingListener.php index cf637fed6..2ddebf3ab 100644 --- a/src/API/Authentication/ApiTokenMigratingListener.php +++ b/src/API/Authentication/ApiTokenMigratingListener.php @@ -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) diff --git a/src/API/Authentication/ApiTokenUpgradeBadge.php b/src/API/Authentication/ApiTokenUpgradeBadge.php index 6af0e2381..b125d41bc 100644 --- a/src/API/Authentication/ApiTokenUpgradeBadge.php +++ b/src/API/Authentication/ApiTokenUpgradeBadge.php @@ -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) diff --git a/src/API/Authentication/TokenAuthenticator.php b/src/API/Authentication/TokenAuthenticator.php index 565342a9d..dd1a82aad 100644 --- a/src/API/Authentication/TokenAuthenticator.php +++ b/src/API/Authentication/TokenAuthenticator.php @@ -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; diff --git a/src/Command/ResetTestCommand.php b/src/Command/ResetTestCommand.php index 99afb1fff..05d295782 100644 --- a/src/Command/ResetTestCommand.php +++ b/src/Command/ResetTestCommand.php @@ -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); diff --git a/src/Constants.php b/src/Constants.php index 5be44388d..b43c30e64 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -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 */ diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 4a227c68d..24d5fca46 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -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'])] diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 28a553603..53fd30428 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -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'])] diff --git a/src/Entity/MetaTableTypeTrait.php b/src/Entity/MetaTableTypeTrait.php index 782463f7e..d162c7533 100644 --- a/src/Entity/MetaTableTypeTrait.php +++ b/src/Entity/MetaTableTypeTrait.php @@ -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] diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 4e2cfadbd..d8bcfbce9 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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'])] diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index d4fb408df..86affae99 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -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; diff --git a/src/Entity/Team.php b/src/Entity/Team.php index 048a52eb3..f3684637b 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -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] diff --git a/src/Entity/User.php b/src/Entity/User.php index 8ad585a68..6103ff084 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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'])] diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 243a215a2..a942d0821 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -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] diff --git a/src/Repository/ApiUserRepository.php b/src/Repository/ApiUserRepository.php index c5f563313..754e080ed 100644 --- a/src/Repository/ApiUserRepository.php +++ b/src/Repository/ApiUserRepository.php @@ -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 diff --git a/src/Twig/ContractExtensions.php b/src/Twig/ContractExtensions.php index 3a6a52340..9611b09f2 100644 --- a/src/Twig/ContractExtensions.php +++ b/src/Twig/ContractExtensions.php @@ -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); + } } diff --git a/src/Validator/Constraints/NoHtmlSpecialCharactersValidator.php b/src/Validator/Constraints/NoHtmlSpecialCharactersValidator.php deleted file mode 100644 index 31eabe59d..000000000 --- a/src/Validator/Constraints/NoHtmlSpecialCharactersValidator.php +++ /dev/null @@ -1,40 +0,0 @@ -') - || 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(); - } - } -} diff --git a/src/Validator/Constraints/NoHtmlSpecialCharacters.php b/src/Validator/Constraints/NoSpecialCharacters.php similarity index 52% rename from src/Validator/Constraints/NoHtmlSpecialCharacters.php rename to src/Validator/Constraints/NoSpecialCharacters.php index c5f6f9b21..fb32efd28 100644 --- a/src/Validator/Constraints/NoHtmlSpecialCharacters.php +++ b/src/Validator/Constraints/NoSpecialCharacters.php @@ -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; diff --git a/src/Validator/Constraints/NoSpecialCharactersValidator.php b/src/Validator/Constraints/NoSpecialCharactersValidator.php new file mode 100644 index 000000000..c7d45a8b4 --- /dev/null +++ b/src/Validator/Constraints/NoSpecialCharactersValidator.php @@ -0,0 +1,43 @@ +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(); + } + } +} diff --git a/templates/invoice/renderer/default.pdf.twig b/templates/invoice/renderer/default.pdf.twig index 343522a94..e2fc4cc94 100644 --- a/templates/invoice/renderer/default.pdf.twig +++ b/templates/invoice/renderer/default.pdf.twig @@ -2,7 +2,7 @@ - {% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %} + {{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }} diff --git a/templates/invoice/renderer/invoice.html.twig b/templates/invoice/renderer/invoice.html.twig index 32b1c515f..7678c4b01 100644 --- a/templates/invoice/renderer/invoice.html.twig +++ b/templates/invoice/renderer/invoice.html.twig @@ -2,7 +2,7 @@ - {% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %} + {{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }} diff --git a/templates/invoice/renderer/service-date.pdf.twig b/templates/invoice/renderer/service-date.pdf.twig index d396d23a8..6d0412e59 100644 --- a/templates/invoice/renderer/service-date.pdf.twig +++ b/templates/invoice/renderer/service-date.pdf.twig @@ -2,7 +2,7 @@ - {% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %} + {{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }} diff --git a/templates/user/form.html.twig b/templates/user/form.html.twig index 453a38680..a21dd664b 100644 --- a/templates/user/form.html.twig +++ b/templates/user/form.html.twig @@ -19,6 +19,7 @@ {% block form_body %} {{ form_start(form) }}
+ {{ form_errors(form) }} {% block form_pre_content %}{% endblock %} {% block form_content %} {{ form_widget(form) }} diff --git a/templates/user/profile.html.twig b/templates/user/profile.html.twig index 1ab5ccc05..2b021ee40 100644 --- a/templates/user/profile.html.twig +++ b/templates/user/profile.html.twig @@ -10,6 +10,7 @@ {% form_theme form 'form/horizontal.html.twig' %} + {{ form_errors(form) }} {{ form_row(form._token) }}
diff --git a/tests/API/Authentication/TokenAuthenticatorTest.php b/tests/API/Authentication/TokenAuthenticatorTest.php index 4689adc7f..09f99d761 100644 --- a/tests/API/Authentication/TokenAuthenticatorTest.php +++ b/tests/API/Authentication/TokenAuthenticatorTest.php @@ -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 diff --git a/tests/Twig/ContractExtensionsTest.php b/tests/Twig/ContractExtensionsTest.php index 762c2fefd..2f5f21794 100644 --- a/tests/Twig/ContractExtensionsTest.php +++ b/tests/Twig/ContractExtensionsTest.php @@ -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; diff --git a/tests/Validator/Constraints/NoHtmlSpecialCharactersValidatorTest.php b/tests/Validator/Constraints/NoSpecialCharactersValidatorTest.php similarity index 67% rename from tests/Validator/Constraints/NoHtmlSpecialCharactersValidatorTest.php rename to tests/Validator/Constraints/NoSpecialCharactersValidatorTest.php index c71712346..7afb83602 100644 --- a/tests/Validator/Constraints/NoHtmlSpecialCharactersValidatorTest.php +++ b/tests/Validator/Constraints/NoSpecialCharactersValidatorTest.php @@ -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 + * @extends ConstraintValidatorTestCase */ -#[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>Foo'], ['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(); } } diff --git a/tests/phpstan.neon b/tests/phpstan.neon index c5411e6d6..93175feeb 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -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