. */ declare(strict_types=1); namespace App\Security; use App\Entity\UserSystem\ApiToken; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; 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\Http\AccessToken\AccessTokenExtractorInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Contracts\Translation\TranslatorInterface; /** * Authenticator similar to the builtin AccessTokenAuthenticator, but we return a Token here which contains information * about the used token. */ class ApiTokenAuthenticator implements AuthenticatorInterface { public function __construct( #[Autowire(service: 'security.access_token_extractor.main')] private readonly AccessTokenExtractorInterface $accessTokenExtractor, private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly string $realm = 'api', ) { } /** * Gets the ApiToken belonging to the given accessToken string. * If the token is invalid or expired, an exception is thrown and authentication fails. * @param string $accessToken * @return ApiToken */ private function getTokenFromString(#[\SensitiveParameter] string $accessToken): ApiToken { $repo = $this->entityManager->getRepository(ApiToken::class); $token = $repo->findOneBy(['token' => $accessToken]); if (!$token instanceof ApiToken) { throw new BadCredentialsException(); } if (!$token->isValid()) { throw new CustomUserMessageAuthenticationException('Token expired'); } $old_time = $token->getLastTimeUsed(); //Set the last used date of the token $token->setLastTimeUsed(new \DateTime()); //Only flush the token if the last used date change is more than 10 minutes //For performance reasons we don't want to flush the token every time it is used, but only if it is used more than 10 minutes after the last time it was used //If a flush is later in the code we don't want to flush the token again if ($old_time === null || $old_time->diff($token->getLastTimeUsed())->i > 10) { $this->entityManager->flush(); } return $token; } public function supports(Request $request): ?bool { return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null; } public function authenticate(Request $request): Passport { $accessToken = $this->accessTokenExtractor->extractAccessToken($request); if (!$accessToken) { throw new BadCredentialsException('Invalid credentials.'); } $apiToken = $this->getTokenFromString($accessToken); $userBadge = new UserBadge($apiToken->getUser()?->getUserIdentifier() ?? throw new BadCredentialsException('Invalid credentials.')); $apiBadge = new ApiTokenBadge($apiToken); return new SelfValidatingPassport($userBadge, [$apiBadge]); } public function createToken(Passport $passport, string $firewallName): TokenInterface { return new ApiTokenAuthenticatedToken( $passport->getUser(), $firewallName, $passport->getUser()->getRoles(), $passport->getBadge(ApiTokenBadge::class)?->getApiToken() ?? throw new \LogicException('Passport does not contain an API token.') ); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { $errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(), 'security'); return new Response( null, Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => $this->getAuthenticateHeader($errorMessage)] ); } /** * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3 */ private function getAuthenticateHeader(string $errorDescription = null): string { $data = [ 'realm' => $this->realm, 'error' => 'invalid_token', 'error_description' => $errorDescription, ]; $values = []; foreach ($data as $k => $v) { if (null === $v || '' === $v) { continue; } $values[] = sprintf('%s="%s"', $k, $v); } return sprintf('Bearer %s', implode(',', $values)); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { return null; } }