Release 2.54 (#5896)

This commit is contained in:
Kevin Papst
2026-04-13 21:22:06 +02:00
committed by GitHub
parent 16703081cd
commit bad92d7215
30 changed files with 232 additions and 91 deletions

View File

@@ -1,5 +1,10 @@
framework: framework:
rate_limiter: rate_limiter:
old_api_tokens:
policy: 'fixed_window'
limit: 5
interval: '1 minute'
lock_factory: null
session_prediction: session_prediction:
policy: 'fixed_window' policy: 'fixed_window'
limit: 250 limit: 250

View File

@@ -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\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])] #[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])] #[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 public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view); $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\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])] #[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])] #[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 public function getActivityActions(Activity $activity, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view); $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\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])] #[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])] #[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 public function getProjectActions(Project $project, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view); $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\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])] #[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])] #[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 public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{ {
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view); $event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -36,8 +36,8 @@ final class ApiRequestMatcher implements RequestMatcherInterface
} }
// let's use this firewall if the deprecated username & token combination is available // let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && // @phpstan-ignore classConstant.deprecatedClass
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { $request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
return true; return true;
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------

View File

@@ -14,6 +14,9 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent; 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 final class ApiTokenMigratingListener implements EventSubscriberInterface
{ {
public function __construct(private PasswordHasherFactoryInterface $hasherFactory) public function __construct(private PasswordHasherFactoryInterface $hasherFactory)

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface; 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 final class ApiTokenUpgradeBadge implements BadgeInterface
{ {
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader) public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)

View File

@@ -13,17 +13,24 @@ use App\Entity\User;
use App\Repository\ApiUserRepository; use App\Repository\ApiUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; 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\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; 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\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport; 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 final class TokenAuthenticator extends AbstractAuthenticator
{ {
public const HEADER_USERNAME = 'X-AUTH-USER'; public const HEADER_USERNAME = 'X-AUTH-USER';
@@ -31,7 +38,9 @@ final class TokenAuthenticator extends AbstractAuthenticator
public function __construct( public function __construct(
private readonly ApiUserRepository $userProvider, 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)) { 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; return true;
} }
} }
@@ -77,10 +84,12 @@ final class TokenAuthenticator extends AbstractAuthenticator
$checkCredentials = function (?string $presentedPassword, User $user) { $checkCredentials = function (?string $presentedPassword, User $user) {
if ('' === $presentedPassword) { if ('' === $presentedPassword) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password cannot be empty.'); throw new BadCredentialsException('The presented password cannot be empty.');
} }
if (null === $user->getApiToken()) { if (null === $user->getApiToken()) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The user has no activated API account.'); throw new BadCredentialsException('The user has no activated API account.');
} }
@@ -88,11 +97,17 @@ final class TokenAuthenticator extends AbstractAuthenticator
return true; return true;
} }
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password is invalid.'); 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( $passport = new Passport(
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']), new UserBadge($credentials['username'], [$this, 'loadUserByIdentifier']),
new CustomCredentials($checkCredentials, $credentials['password']) new CustomCredentials($checkCredentials, $credentials['password'])
); );
@@ -101,6 +116,30 @@ final class TokenAuthenticator extends AbstractAuthenticator
return $passport; 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 public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{ {
return null; return null;

View File

@@ -147,7 +147,6 @@ final class ResetTestCommand extends AbstractResetCommand
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46); $userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57')); $userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userSuperAdmin->setTitle('Super Administrator'); $userSuperAdmin->setTitle('Super Administrator');
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$userSuperAdmin->setEnabled(true); $userSuperAdmin->setEnabled(true);
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']); $userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN); $userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);

View File

@@ -17,11 +17,11 @@ final class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.53.0'; public const VERSION = '2.54.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 25300; public const VERSION_ID = 25400;
/** /**
* The software name * The software name
*/ */

View File

@@ -60,6 +60,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Name of this activity * Name of this activity
*/ */
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)] #[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose] #[Serializer\Expose]
@@ -117,6 +118,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)] #[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null; private ?string $invoiceText = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)] #[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)] #[Assert\Length(max: 10)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]

View File

@@ -45,6 +45,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'id', type: 'integer')] #[Exporter\Expose(label: 'id', type: 'integer')]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)] #[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose] #[Serializer\Expose]
@@ -52,6 +53,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'name')] #[Exporter\Expose(label: 'name')]
private ?string $name = null; private ?string $name = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)] #[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 50)] #[Assert\Length(max: 50)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity; namespace App\Entity;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer; use JMS\Serializer\Annotation as Serializer;
@@ -30,6 +31,7 @@ trait MetaTableTypeTrait
* Name of the meta (custom) field * Name of the meta (custom) field
*/ */
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull] #[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)] #[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose] #[Serializer\Expose]

View File

@@ -63,6 +63,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Project name * Project name
*/ */
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull] #[Assert\NotNull]
#[Assert\Length(min: 2, max: 150)] #[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose] #[Serializer\Expose]
@@ -170,6 +171,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private bool $globalActivities = true; private bool $globalActivities = true;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)] #[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)] #[Assert\Length(max: 10)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity; namespace App\Entity;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer; use JMS\Serializer\Annotation as Serializer;
@@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class Tag class Tag
{ {
/** /**
* Internal Tag ID * Tag ID
*/ */
#[ORM\Column(name: 'id', type: Types::INTEGER)] #[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id] #[ORM\Id]
@@ -35,9 +36,10 @@ class Tag
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private ?int $id = null; private ?int $id = null;
/** /**
* The tag name * Tag name cannot contain the character: " < > = ,
*/ */
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')] #[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)] #[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
@@ -52,10 +54,6 @@ class Tag
use ColorTrait; use ColorTrait;
public function __construct()
{
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;

View File

@@ -39,6 +39,7 @@ class Team
* Team name * Team name
*/ */
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)] #[Assert\Length(min: 2, max: 100)]
#[Serializer\Expose] #[Serializer\Expose]

View File

@@ -81,7 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/ */
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)] #[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
#[Assert\Length(max: 60)] #[Assert\Length(max: 60)]
#[Constraints\NoHtmlSpecialCharacters] #[Constraints\NoSpecialCharacters]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')] #[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)] #[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)] #[Assert\Length(max: 50)]
#[Constraints\NoHtmlSpecialCharacters] #[Constraints\NoSpecialCharacters]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'title')] #[Exporter\Expose(label: 'title')]
private ?string $title = null; 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)] #[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Url]
#[Assert\Length(max: 255, groups: ['Profile'])] #[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
@@ -164,7 +165,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])] #[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])] #[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])] #[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Constraints\NoHtmlSpecialCharacters] #[Constraints\NoSpecialCharacters]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private ?string $username = null; private ?string $username = null;
@@ -176,6 +177,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private ?string $email = null; private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)] #[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 30)] #[Assert\Length(max: 30)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity; namespace App\Entity;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay; use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -61,6 +62,7 @@ class UserPreference
#[Assert\NotNull] #[Assert\NotNull]
private ?User $user = null; private ?User $user = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)] #[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull] #[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)] #[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose] #[Serializer\Expose]

View File

@@ -11,10 +11,14 @@ namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; 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\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface; 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 class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{ {
public function __construct(private readonly UserRepository $userRepository) public function __construct(private readonly UserRepository $userRepository)
@@ -23,7 +27,11 @@ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterfac
public function loadUserByIdentifier(string $identifier): ?UserInterface 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 public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void

View File

@@ -29,20 +29,30 @@ final class ContractExtensions extends AbstractExtension
return [ return [
/* @var array{user: User, date: \DateTimeInterface} $values */ /* @var array{user: User, date: \DateTimeInterface} $values */
new TwigTest('work_day', function (array $values): bool { new TwigTest('work_day', function (array $values): bool {
$user = $values['user']; // TODO remove me in 3.0, deprecate with 2.55
if ($user->getId() === null) { if (!\array_key_exists('user', $values) || !\array_key_exists('date', $values)) {
return false; throw new \Exception('Missing variable "user" or "date" to check for "is work_day');
} }
$id = 'user_' . $user->getId(); return $this->isWorkingDay($values['date'], $values['user']);
if (!\array_key_exists($id, $this->calculators)) { }),
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user); new TwigTest('working_day', function (\DateTimeInterface $date, User $user): bool {
} return $this->isWorkingDay($date, $user);
$date = $values['date'];
return $this->calculators[$id]->isWorkDay($date);
}), }),
]; ];
} }
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);
}
} }

View File

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

View File

@@ -12,7 +12,7 @@ namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)] #[\Attribute(\Attribute::TARGET_PROPERTY)]
final class NoHtmlSpecialCharacters extends Constraint final class NoSpecialCharacters extends Constraint
{ {
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001'; 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 }}', 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 }}'; 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 public function getTargets(): string
{ {
return self::PROPERTY_CONSTRAINT; return self::PROPERTY_CONSTRAINT;

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

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}"> <html lang="{{ invoice['invoice.language'] }}">
<head> <head>
<meta charset="utf-8"> <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> <style>
{{ encore_entry_css_source('invoice-pdf')|raw }} {{ encore_entry_css_source('invoice-pdf')|raw }}
</style> </style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}"> <html lang="{{ invoice['invoice.language'] }}">
<head> <head>
<meta charset="utf-8"> <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> <style>
{{ encore_entry_css_source('invoice')|raw }} {{ encore_entry_css_source('invoice')|raw }}
</style> </style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}"> <html lang="{{ invoice['invoice.language'] }}">
<head> <head>
<meta charset="utf-8"> <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> <style>
{{ encore_entry_css_source('invoice-pdf')|raw }} {{ encore_entry_css_source('invoice-pdf')|raw }}
</style> </style>

View File

@@ -19,6 +19,7 @@
{% block form_body %} {% block form_body %}
{{ form_start(form) }} {{ form_start(form) }}
<div class="card-body {% block form_body_class %}{% endblock %}"> <div class="card-body {% block form_body_class %}{% endblock %}">
{{ form_errors(form) }}
{% block form_pre_content %}{% endblock %} {% block form_pre_content %}{% endblock %}
{% block form_content %} {% block form_content %}
{{ form_widget(form) }} {{ form_widget(form) }}

View File

@@ -10,6 +10,7 @@
{% form_theme form 'form/horizontal.html.twig' %} {% form_theme form 'form/horizontal.html.twig' %}
{{ form_errors(form) }}
{{ form_row(form._token) }} {{ form_row(form._token) }}
<fieldset class="form-fieldset form-fieldset-light"> <fieldset class="form-fieldset form-fieldset-light">

View File

@@ -16,8 +16,11 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface; 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\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
@@ -35,7 +38,12 @@ class TokenAuthenticatorTest extends TestCase
$passwordHasher->method('verify')->willReturn($verify); $passwordHasher->method('verify')->willReturn($verify);
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher); $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 public function testSupports(): void

View File

@@ -29,7 +29,7 @@ class ContractExtensionsTest extends TestCase
public function testDefinedMethods(): void 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()->getFilters());
self::assertCount(0, $this->getSut()->getFunctions()); self::assertCount(0, $this->getSut()->getFunctions());
} }
@@ -38,6 +38,7 @@ class ContractExtensionsTest extends TestCase
{ {
$filters = [ $filters = [
'work_day', 'work_day',
'working_day',
]; ];
$i = 0; $i = 0;

View File

@@ -9,8 +9,8 @@
namespace App\Tests\Validator\Constraints; namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\NoHtmlSpecialCharacters; use App\Validator\Constraints\NoSpecialCharacters;
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator; use App\Validator\Constraints\NoSpecialCharactersValidator;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
@@ -18,15 +18,15 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/** /**
* @extends ConstraintValidatorTestCase<NoHtmlSpecialCharactersValidator> * @extends ConstraintValidatorTestCase<NoSpecialCharactersValidator>
*/ */
#[CoversClass(NoHtmlSpecialCharacters::class)] #[CoversClass(NoSpecialCharacters::class)]
#[CoversClass(NoHtmlSpecialCharactersValidator::class)] #[CoversClass(NoSpecialCharactersValidator::class)]
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase class NoSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
{ {
protected function createValidator(): NoHtmlSpecialCharactersValidator protected function createValidator(): NoSpecialCharactersValidator
{ {
return new NoHtmlSpecialCharactersValidator(); return new NoSpecialCharactersValidator();
} }
public function testConstraintIsInvalid(): void public function testConstraintIsInvalid(): void
@@ -38,7 +38,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
public function testGetTargets(): void public function testGetTargets(): void
{ {
$constraint = new NoHtmlSpecialCharacters(); $constraint = new NoSpecialCharacters();
self::assertEquals('property', $constraint->getTargets()); self::assertEquals('property', $constraint->getTargets());
} }
@@ -47,7 +47,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
return [ return [
[''], [''],
[null], [null],
['asdf-.,123!§$%&/()=?`4567\'890ß'], ['asdf-.,123!§$%&/()?`4567\'890ß'],
]; ];
} }
@@ -57,7 +57,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator(); $this->validator = $this->createValidator();
$this->validator->initialize($this->context); $this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters()); $this->validator->validate($data, new NoSpecialCharacters());
$this->assertNoViolation(); $this->assertNoViolation();
} }
@@ -68,6 +68,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
['Test" onclick="alert(1)"'], ['Test" onclick="alert(1)"'],
['Test><a href=#>Foo</a>'], ['Test><a href=#>Foo</a>'],
['Test" broken string'], ['Test" broken string'],
['I am not = allowed'],
]; ];
} }
@@ -77,11 +78,11 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator(); $this->validator = $this->createValidator();
$this->validator->initialize($this->context); $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 }}') $this->buildViolation('These characters are not allowed: {{ chars }}')
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND) ->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< " >') ->setParameter('{{ chars }}', '< > " =')
->assertRaised(); ->assertRaised();
} }
} }

View File

@@ -27,6 +27,26 @@ parameters:
- -
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#" 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\\.$#" message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
count: 1 count: 1