Added own APIToken authenticator, so we can wrap the used API token inside the symfony security token

This commit is contained in:
Jan Böhmer 2023-08-17 00:17:02 +02:00
parent bcd41c4d9b
commit 8dad143f8d
10 changed files with 391 additions and 67 deletions

View file

@ -21,12 +21,12 @@ security:
user_checker: App\Security\UserChecker
entry_point: form_login
access_token:
token_handler: App\Security\ApiTokenHandler
# Enable user impersonation
switch_user: { role: CAN_SWITCH_USER }
custom_authenticators:
- App\Security\ApiTokenAuthenticator
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check

View file

@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230815212203 extends AbstractMigration
final class Version20230816213201 extends AbstractMigration
{
public function getDescription(): string
{
@ -20,7 +20,7 @@ final class Version20230815212203 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE api_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, valid_until DATETIME DEFAULT NULL, token VARCHAR(68) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX UNIQ_2CAD560E5F37A13B (token), INDEX IDX_2CAD560EA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE api_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, valid_until DATETIME DEFAULT NULL, token VARCHAR(68) NOT NULL, level SMALLINT NOT NULL, last_time_used DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX UNIQ_2CAD560E5F37A13B (token), INDEX IDX_2CAD560EA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id)');
}

View file

@ -24,6 +24,7 @@ namespace App\Controller;
use App\Entity\Attachments\Attachment;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\U2FKey;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey;
@ -41,6 +42,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -405,6 +407,7 @@ class UserSettingsController extends AbstractController
public function addApiToken(Request $request, EntityManagerInterface $entityManager): Response
{
$token = new ApiToken();
$token->setUser($this->getUser());
$secret = null;
@ -418,6 +421,10 @@ class UserSettingsController extends AbstractController
'required' => false,
'html5' => true
])
->add('level', EnumType::class, [
'class' => ApiTokenLevel::class,
'label' => 'user.api_token.level',
])
->add('submit', SubmitType::class, [
'label' => 'save',
])
@ -426,7 +433,6 @@ class UserSettingsController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$token->setUser($this->getUser());
$entityManager->persist($token);
$entityManager->flush();

View file

@ -58,6 +58,12 @@ class ApiToken
#[ORM\Column(length: 68, unique: true)]
private string $token;
#[ORM\Column(type: Types::SMALLINT, enumType: ApiTokenLevel::class)]
private ApiTokenLevel $level = ApiTokenLevel::READ_ONLY;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $last_time_used = null;
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
{
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
@ -121,5 +127,36 @@ class ApiToken
return $this;
}
/**
* Gets the last time the token was used to authenticate or null if it was never used.
* @return \DateTimeInterface|null
*/
public function getLastTimeUsed(): ?\DateTimeInterface
{
return $this->last_time_used;
}
/**
* Sets the last time the token was used to authenticate.
* @param \DateTimeInterface|null $last_time_used
* @return ApiToken
*/
public function setLastTimeUsed(?\DateTimeInterface $last_time_used): ApiToken
{
$this->last_time_used = $last_time_used;
return $this;
}
public function getLevel(): ApiTokenLevel
{
return $this->level;
}
public function setLevel(ApiTokenLevel $level): ApiToken
{
$this->level = $level;
return $this;
}
}

View file

@ -0,0 +1,58 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\UserSystem;
enum ApiTokenLevel: int
{
private const ROLE_READ_ONLY = 'ROLE_API_READ_ONLY';
private const ROLE_EDIT = 'ROLE_API_EDIT';
private const ROLE_FULL = 'ROLE_API_FULL';
/**
* The token can only read (non-sensitive) data.
*/
case READ_ONLY = 1;
/**
* The token can read and edit (non-sensitive) data.
*/
case EDIT = 2;
/**
* The token can do everything the user can do.
*/
case FULL = 3;
/**
* Returns the additional roles that the authenticated user should have when using this token.
* @return string[]
*/
public function getAdditionalRoles(): array
{
//The higher roles should always include the lower ones
return match ($this) {
self::READ_ONLY => [self::ROLE_READ_ONLY],
self::EDIT => [self::ROLE_READ_ONLY, self::ROLE_EDIT],
self::FULL => [self::ROLE_READ_ONLY, self::ROLE_EDIT, self::ROLE_FULL],
};
}
}

View file

@ -315,6 +315,11 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $saml_user = false;
/**
* @var ApiToken|null The api token which is used to authenticate the user, or null if the user is not authenticated via api token
*/
private ?ApiToken $authenticating_api_token = null;
public function __construct()
{
$this->attachments = new ArrayCollection();
@ -1035,6 +1040,23 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
$this->api_tokens->removeElement($apiToken);
}
/**
* Mark the user as authenticated with an API token, should only be used by the API token authenticator.
* @param ApiToken $apiToken
* @return void
*/
public function markAsApiTokenAuthenticated(ApiToken $apiToken): void
{
$this->authenticating_api_token = $apiToken;
}
/**
* Return the API token that is currently authenticating the user or null if the user is not authenticated with an API token.
* @return ApiToken|null
*/
public function getAuthenticatingApiToken(): ?ApiToken
{
return $this->authenticating_api_token;
}
}

View file

@ -0,0 +1,52 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\ApiToken;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
class ApiTokenAuthenticatedToken extends PostAuthenticationToken
{
public function __construct(UserInterface $user, string $firewallName, array $roles, private readonly ApiToken $apiToken)
{
//Add roles for the API
$roles[] = 'ROLE_API_AUTHENTICATED';
//Add roles based on the token level
$roles = array_merge($roles, $apiToken->getLevel()->getAdditionalRoles());
parent::__construct($user, $firewallName, array_unique($roles));
}
/**
* Returns the API token that was used to authenticate the user.
* @return ApiToken
*/
public function getApiToken(): ApiToken
{
return $this->apiToken;
}
}

View file

@ -0,0 +1,159 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.header')]
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 \DateTimeImmutable());
//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
{
if (null !== $this->translator) {
$errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(),
'security');
} else {
$errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData());
}
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;
}
}

View file

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\ApiToken;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class ApiTokenBadge implements BadgeInterface
{
/**
* @param ApiToken $apiToken
*/
public function __construct(private readonly ApiToken $apiToken)
{
}
/**
* @return ApiToken The token that was used to authenticate the user
*/
public function getApiToken(): ApiToken
{
return $this->apiToken;
}
public function isResolved(): bool
{
return true;
}
}

View file

@ -1,61 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\ApiToken;
use App\Repository\UserSystem\ApiTokenRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
private readonly ApiTokenRepository $apiTokenRepository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->apiTokenRepository = $entityManager->getRepository(ApiToken::class);
}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
if (!$token instanceof ApiToken) {
throw new BadCredentialsException();
}
if (!$token->isValid()) {
throw new CustomUserMessageAuthenticationException('Token expired');
}
return new UserBadge(
userIdentifier: $token->getUser()?->getUserIdentifier() ?? throw new BadCredentialsException(),
);
}
}