Release 2.54 (#5896)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user