feat: remove legacy UserToken based auth

This commit is contained in:
2026-02-24 16:49:54 +01:00
parent 72bb2b8a61
commit 75a39058fe
16 changed files with 14 additions and 518 deletions
-4
View File
@@ -79,10 +79,6 @@ services:
arguments:
- '%env(APP_ENV)%'
App\Service\Auth\AuthService:
arguments:
- '%env(APP_SECRET)%'
App\Service\LocalizationService:
arguments:
- '%kernel.default_locale%'
@@ -1,44 +0,0 @@
<?php
namespace App\ApiResource\User;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\Dto\UserTokenLoginDto;
use App\Entity\User\UserToken;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use App\State\User\UserTokenLoginProcessor;
/**
* UserTokens authenticate requests on behalf of the User who owns them.\
* \
* When a UserToken is created v4 generates a SHA-256 hash that is unique for each UserToken.
* The value of a UserToken comes preceded by a 4-digit-length prefix based on the type of token it is.
*/
#[API\ApiResource(
shortName: 'UserToken',
stateOptions: new Options(entityClass: UserToken::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\Post(input: UserTokenLoginDto::class, processor: UserTokenLoginProcessor::class)]
#[API\Get(security: 'is_granted("USER_OWNED", object)')]
#[API\Delete(security: 'is_granted("USER_OWNED", object)')]
class UserTokenApiResource
{
#[API\ApiProperty(writable: false, identifier: true)]
public int $id;
/**
* The User token itself.
*/
#[API\ApiProperty(writable: false)]
public string $token;
/**
* The User who this token grants access as.
*/
#[API\ApiProperty(writable: false)]
public UserApiResource $owner;
}
-32
View File
@@ -1,32 +0,0 @@
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* UserTokens are created via a login flow where v4:
* 1. Receives the User credentials
* 2. Validates them
* 3. Generates the UserToken for the authenticated User
*/
final class UserTokenLoginDto
{
/**
* The identifier (email, handle) of the User to be authenticated.
*/
#[Assert\NotBlank()]
public readonly string $identifier;
/**
* The password of the User to be authenticated.
*/
#[Assert\NotBlank()]
public readonly string $password;
public function __construct(string $identifier, string $password)
{
$this->identifier = $identifier;
$this->password = $password;
}
}
-37
View File
@@ -72,12 +72,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Project::class, cascade: ['persist'])]
private Collection $projects;
/**
* The UserTokens owned by this User. Owner only property.
*/
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: UserToken::class, orphanRemoval: true)]
private Collection $tokens;
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Person $person = null;
@@ -128,7 +122,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
$this->accounting = Accounting::of($this);
$this->projects = new ArrayCollection();
$this->tokens = new ArrayCollection();
$this->person = Person::for($this);
$this->emailConfirmed = false;
@@ -236,36 +229,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
return $this;
}
/**
* @return Collection<int, UserToken>
*/
public function getTokens(): Collection
{
return $this->tokens;
}
public function addToken(UserToken $token): static
{
if (!$this->tokens->contains($token)) {
$this->tokens->add($token);
$token->setOwner($this);
}
return $this;
}
public function removeToken(UserToken $token): static
{
if ($this->tokens->removeElement($token)) {
// set the owning side to null (unless already changed)
if ($token->getOwner() === $this) {
$token->setOwner(null);
}
}
return $this;
}
public function getPerson(): ?Person
{
return $this->person;
-63
View File
@@ -1,63 +0,0 @@
<?php
namespace App\Entity\User;
use App\Entity\DateCreatedTrait;
use App\Entity\UserOwnedInterface;
use App\Entity\UserOwnedTrait;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\User\UserTokenRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\ORM\Mapping as ORM;
/**
* UserTokens authenticate requests on behalf of the User who owns them.\
* \
* When a UserToken is created v4 generates a SHA-256 hash that is unique for that Token and the User it represents.
* The value of a token comes preceded by a 4-digit-length prefix based on the type of token it is.\
* \
* `oat_` means the token was created via an OAuth flow.\
* `pat_` means the token was created via a login flow.
*/
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: UserTokenRepository::class)]
class UserToken implements UserOwnedInterface
{
use DateCreatedTrait;
use UserOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The value to be used in the Authorization header.
*/
#[ORM\Column(length: 68)]
private ?string $token = null;
/**
* The User on behalf of which this UserToken authenticates.
*/
#[ORM\ManyToOne(inversedBy: 'tokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
public function getId(): ?int
{
return $this->id;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
}
@@ -1,63 +0,0 @@
<?php
namespace App\EventSubscriber;
use ApiPlatform\Symfony\EventListener\EventPriorities;
use App\ApiResource\User\UserTokenApiResource;
use App\Service\Auth\AuthService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class AuthorizationTokenSubscriber implements EventSubscriberInterface
{
public function __construct(
private AuthService $authService,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::VIEW => ['onView', EventPriorities::POST_WRITE],
KernelEvents::RESPONSE => ['onResponse', EventPriorities::POST_RESPOND],
];
}
public function onView(ViewEvent $event): void
{
$resourceClass = $event->getRequest()->attributes->get('_api_resource_class');
if ($resourceClass !== UserTokenApiResource::class) {
return;
}
$event->getRequest()->attributes->set('authTokenMethod', $event->getRequest()->getMethod());
if ($event->getRequest()->getMethod() === Request::METHOD_POST) {
$token = $event->getControllerResult();
$event->getRequest()->attributes->set('authTokenValue', $token->token);
}
}
public function onResponse(ResponseEvent $event): void
{
$token = $event->getRequest()->attributes->get('authTokenValue');
$method = $event->getRequest()->attributes->get('authTokenMethod');
if (!$method) {
return;
}
switch ($method) {
case Request::METHOD_DELETE:
$event->getResponse()->headers->clearCookie(AuthService::AUTH_COOKIE_NAME);
break;
case Request::METHOD_POST:
$event->getResponse()->headers->setCookie($this->authService->generateCookie($token));
break;
}
}
}
-1
View File
@@ -23,7 +23,6 @@ trait RedocExtensionsTrait
'name' => 'Users',
'tags' => [
'User',
'UserToken',
'Person',
'Organization',
],
@@ -1,48 +0,0 @@
<?php
namespace App\Repository\User;
use App\Entity\User\UserToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserToken>
*
* @method UserToken|null find($id, $lockMode = null, $lockVersion = null)
* @method UserToken|null findOneBy(array $criteria, array $orderBy = null)
* @method UserToken[] findAll()
* @method UserToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserToken::class);
}
// /**
// * @return UserToken[] Returns an array of UserToken objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?UserToken
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Security;
use App\Repository\User\UserTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private UserTokenRepository $userTokenRepository) {}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->userTokenRepository->findOneBy(['token' => $accessToken]);
if (!$token) {
throw new BadCredentialsException();
}
return new UserBadge($token->getOwner()->getUserIdentifier());
}
}
-56
View File
@@ -1,56 +0,0 @@
<?php
namespace App\Security;
use App\Service\Auth\AuthService;
use Symfony\Component\HttpFoundation\JsonResponse;
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\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class CookieAuthenticator extends AbstractAuthenticator
{
public function __construct(
private AccessTokenHandler $accessTokenHandler,
) {}
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning `false` will cause this authenticator
* to be skipped.
*/
public function supports(Request $request): ?bool
{
return $request->cookies->has(AuthService::AUTH_COOKIE_NAME);
}
public function authenticate(Request $request): Passport
{
$token = $request->cookies->get(AuthService::AUTH_COOKIE_NAME);
if (!$token) {
throw new AuthenticationException();
}
return new SelfValidatingPassport($this->accessTokenHandler->getUserBadgeFrom($token));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
], Response::HTTP_UNAUTHORIZED);
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace App\Service\Auth;
use App\Entity\User\User;
use App\Entity\User\UserToken;
use App\Repository\User\UserRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Cookie;
class AuthService
{
public const AUTH_COOKIE_NAME = 'authToken';
public const AUTH_COOKIE_TTL = 86400;
private const TOKEN_HASH_ALGO = 'sha256';
private array $config;
public function __construct(
private string $appSecret,
private Security $security,
private UserRepository $userRepository,
) {}
/**
* @return array{CORS_ALLOW_ORIGIN: string, SESSION_LIFETIME: int}
*/
public function getConfig(): array
{
return $this->config;
}
private function getCookieTtl(): \DateTimeInterface
{
return (new \DateTime())->add(new \DateInterval(\sprintf('PT%dS', AuthService::AUTH_COOKIE_TTL)));
}
public function generateCookie(string $token): Cookie
{
return new Cookie(
name: AuthService::AUTH_COOKIE_NAME,
value: $token,
expire: $this->getCookieTtl(),
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict'
);
}
public function generateUserToken(User $user, AuthTokenType $type): UserToken
{
$token = new UserToken();
$token->setOwner($user);
$token->setToken(sprintf('%s%s', $type->value, hash(
self::TOKEN_HASH_ALGO,
join('', [
microtime(true),
$this->appSecret,
random_bytes(32),
$user->getUserIdentifier(),
])
)));
return $token;
}
public function getUser(): ?User
{
$loggedInUser = $this->security->getUser();
if (!$loggedInUser) {
return null;
}
return $this->userRepository->findOneBy(
['handle' => $loggedInUser->getUserIdentifier()]
);
}
}
+3 -3
View File
@@ -10,8 +10,8 @@ use App\Dto\ProjectUpdationDto;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCalendar;
use App\Mapping\AutoMapper;
use App\Service\Auth\AuthService;
use App\State\EntityStateProcessor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class ProjectStateProcessor implements ProcessorInterface
@@ -19,7 +19,7 @@ class ProjectStateProcessor implements ProcessorInterface
public function __construct(
private EntityStateProcessor $entityStateProcessor,
private AutoMapper $autoMapper,
private AuthService $authService,
private Security $security,
) {}
/**
@@ -52,7 +52,7 @@ class ProjectStateProcessor implements ProcessorInterface
/** @var Project */
$project = $this->autoMapper->map($data, Project::class);
$owner = $this->authService->getUser();
$owner = $this->security->getUser();
if (!$owner) {
throw new AuthenticationException();
@@ -8,8 +8,8 @@ use App\ApiResource\Project\RewardClaimApiResource;
use App\Dto\RewardClaimCreationDto;
use App\Entity\Project\RewardClaim;
use App\Mapping\AutoMapper;
use App\Service\Auth\AuthService;
use App\State\EntityStateProcessor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class RewardClaimStateProcessor implements ProcessorInterface
@@ -17,7 +17,7 @@ class RewardClaimStateProcessor implements ProcessorInterface
public function __construct(
private EntityStateProcessor $entityStateProcessor,
private AutoMapper $autoMapper,
private AuthService $authService,
private Security $security,
) {}
/**
@@ -31,7 +31,7 @@ class RewardClaimStateProcessor implements ProcessorInterface
$claim = $this->autoMapper->map($data, RewardClaim::class);
if ($data instanceof RewardClaimCreationDto) {
$owner = $this->authService->getUser();
$owner = $this->security->getUser();
if (!$owner) {
throw new AuthenticationException();
+4 -3
View File
@@ -7,16 +7,16 @@ use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Project\SupportApiResource;
use App\Entity\Project\Support;
use App\Mapping\AutoMapper;
use App\Service\Auth\AuthService;
use App\State\EntityStateProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class SupportStateProcessor implements ProcessorInterface
{
public function __construct(
private EntityStateProcessor $entityStateProcessor,
private AuthService $authService,
private Security $security,
private AutoMapper $autoMapper,
private EntityManagerInterface $entityManager,
) {}
@@ -28,7 +28,8 @@ class SupportStateProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$user = $this->authService->getUser();
$user = $this->security->getUser();
if (!$user) {
throw new AuthenticationException();
}
+4 -3
View File
@@ -7,8 +7,8 @@ use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Project\UpdateApiResource;
use App\Entity\Project\Update;
use App\Mapping\AutoMapper;
use App\Service\Auth\AuthService;
use App\State\EntityStateProcessor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class UpdateStateProcessor implements ProcessorInterface
@@ -16,7 +16,7 @@ class UpdateStateProcessor implements ProcessorInterface
public function __construct(
private EntityStateProcessor $entityStateProcessor,
private AutoMapper $autoMapper,
private AuthService $authService,
private Security $security,
) {}
/**
@@ -29,7 +29,8 @@ class UpdateStateProcessor implements ProcessorInterface
/** @var Update */
$update = $this->autoMapper->map($data, Update::class);
$user = $this->authService->getUser();
$user = $this->security->getUser();
if (!$user) {
throw new AuthenticationException();
}
@@ -1,52 +0,0 @@
<?php
namespace App\State\User;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\User\UserTokenApiResource;
use App\Dto\UserTokenLoginDto;
use App\Mapping\AutoMapper;
use App\Repository\User\UserRepository;
use App\Service\Auth\AuthService;
use App\Service\Auth\AuthTokenType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserTokenLoginProcessor implements ProcessorInterface
{
public function __construct(
private AuthService $authService,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $userPasswordHasher,
private AutoMapper $autoMapper,
) {}
/**
* @param UserTokenLoginDto $data
*
* @return UserTokenApiResource
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$user = $this->userRepository->findOneByIdentifier($data->identifier);
if (!$user) {
throw new HttpException(Response::HTTP_NOT_FOUND, sprintf("The user '%s' does not exist.", $data->identifier));
}
if (!$this->userPasswordHasher->isPasswordValid($user, $data->password)) {
throw new HttpException(Response::HTTP_UNAUTHORIZED, sprintf('The password could not be validated'));
}
$token = $this->authService->generateUserToken($user, AuthTokenType::Personal);
$this->entityManager->persist($token);
$this->entityManager->flush();
return $this->autoMapper->map($token, UserTokenApiResource::class);
}
}