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

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

View File

@@ -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;
}
// ------------------------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;