Update auth, decouple user tokens from access tokens

This commit is contained in:
2024-03-12 17:12:27 +01:00
parent 460ea9323e
commit 56282c8dec
10 changed files with 79 additions and 74 deletions
@@ -4,7 +4,10 @@ namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class AccessTokenLoginDto
/**
* What the fuck?
*/
final class UserTokenLoginDto
{
/**
* The username of the User to be authenticated.
+15 -15
View File
@@ -78,11 +78,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?Accounting $accounting = null;
/**
* The AccessTokens owned by this user. Owner only property.
* The UserTokens owned by this user. Owner only property.
*/
#[API\ApiProperty(writable: false, readableLink: true, security: 'is_granted("AUTH_OWNER", object)')]
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: AccessToken::class, orphanRemoval: true)]
private Collection $accessTokens;
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: UserToken::class, orphanRemoval: true)]
private Collection $tokens;
#[ORM\Column]
private ?bool $active = null;
@@ -101,7 +101,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->accounting = new Accounting();
$this->accounting->setOwnerClass(User::class);
$this->accessTokens = new ArrayCollection();
$this->tokens = new ArrayCollection();
}
public function getId(): ?int
@@ -227,29 +227,29 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
/**
* @return Collection<int, AccessToken>
* @return Collection<int, UserToken>
*/
public function getAccessTokens(): Collection
public function getTokens(): Collection
{
return $this->accessTokens;
return $this->tokens;
}
public function addAccessToken(AccessToken $accessToken): static
public function addToken(UserToken $token): static
{
if (!$this->accessTokens->contains($accessToken)) {
$this->accessTokens->add($accessToken);
$accessToken->setOwnedBy($this);
if (!$this->tokens->contains($token)) {
$this->tokens->add($token);
$token->setOwnedBy($this);
}
return $this;
}
public function removeAccessToken(AccessToken $accessToken): static
public function removeToken(UserToken $token): static
{
if ($this->accessTokens->removeElement($accessToken)) {
if ($this->tokens->removeElement($token)) {
// set the owning side to null (unless already changed)
if ($accessToken->getOwnedBy() === $this) {
$accessToken->setOwnedBy(null);
if ($token->getOwnedBy() === $this) {
$token->setOwnedBy(null);
}
}
@@ -3,24 +3,24 @@
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Dto\AccessTokenLoginDto;
use App\Repository\AccessTokenRepository;
use App\State\AccessTokenLoginProcessor;
use App\Dto\UserTokenLoginDto;
use App\Repository\UserTokenRepository;
use App\State\UserTokenLoginProcessor;
use Doctrine\ORM\Mapping as ORM;
/**
* AccessTokens authenticate requests on behalf of the User who owns them.\
* UserTokens authenticates requests on behalf of the User who owns them.\
* \
* When an AccessToken is created the system generates a SHA-256 hash that is unique for the User it represents.
* 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.
*/
#[API\Post(input: AccessTokenLoginDto::class, processor: AccessTokenLoginProcessor::class)]
#[API\Post(input: UserTokenLoginDto::class, processor: UserTokenLoginProcessor::class)]
#[API\Delete(security: 'is_granted("AUTH_OWNER")')]
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
class AccessToken
#[ORM\Entity(repositoryClass: UserTokenRepository::class)]
class UserToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -34,9 +34,9 @@ class AccessToken
private ?string $token = null;
/**
* The User on behalf of which this Token authenticates.
* The User on behalf of which this UserToken authenticates.
*/
#[ORM\ManyToOne(inversedBy: 'accessTokens')]
#[ORM\ManyToOne(inversedBy: 'tokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $ownedBy = null;
+7 -5
View File
@@ -12,15 +12,17 @@ This API is still in early development and is not set to have backward compatibi
# Authentication
The v4 API uses AccessTokens to authenticate requests. AccessTokens exist under an User's scope, when you obtain a token this will only grant you as much permissions as the User under which it was created.
To obtain an AccessToken you must send a POST request to [/v4/access_tokens](/v4/access_tokens) with the User login credentials (username and password) in the payload. If the credentials are correct an AccessToken will be created, the `token` property value of which you must include in future requests Authorization header using the [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) strategy.
The v4 API uses Access Tokens to authenticate requests. To use a Token, include it in the Authorization header using the [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/) strategy.
```shell
curl -X 'GET' \
'https://api.goteo.org/v4/projects?page=1' \
-H 'accept: application/json' \
-H 'Authorization: Bearer <YOUR-ACCESS-TOKEN-HERE>'
-H 'Authorization: Bearer <THE-TOKEN-HERE>'
```
Users can delete AccessTokens owned by them at any moment, revoking your application's access to the v4 API on their behalf. When an AccessToken fails to authenticate a request you will receive a 401 Unauthorized response from the API, at which point your application must look to get a new AccessToken from the User.
UserTokens exist under an User's scope, when you obtain a token this will only grant you as much permissions as the User under which it was created.
To obtain a UserToken you must send a POST request to [/v4/user_tokens](/v4/user_tokens) with the User login credentials (username and password) in the payload. If the credentials are correct a UserToken will be created, the `token` property value of which you must include in future requests.
Users can delete UserTokens owned by them at any moment, revoking your application's access to the v4 API on their behalf. When a UserToken fails to authenticate a request you will receive a 401 Unauthorized response from the API, at which point your application must look to get a new UserToken from the User.
@@ -2,27 +2,27 @@
namespace App\Repository;
use App\Entity\AccessToken;
use App\Entity\UserToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccessToken>
* @extends ServiceEntityRepository<UserToken>
*
* @method AccessToken|null find($id, $lockMode = null, $lockVersion = null)
* @method AccessToken|null findOneBy(array $criteria, array $orderBy = null)
* @method AccessToken[] findAll()
* @method AccessToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
* @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 AccessTokenRepository extends ServiceEntityRepository
class UserTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccessToken::class);
parent::__construct($registry, UserToken::class);
}
// /**
// * @return AccessToken[] Returns an array of AccessToken objects
// * @return UserToken[] Returns an array of UserToken objects
// */
// public function findByExampleField($value): array
// {
@@ -36,7 +36,7 @@ class AccessTokenRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?AccessToken
// public function findOneBySomeField($value): ?UserToken
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
+3 -3
View File
@@ -2,20 +2,20 @@
namespace App\Security;
use App\Repository\AccessTokenRepository;
use App\Repository\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 AccessTokenRepository $accessTokenRepository)
public function __construct(private UserTokenRepository $userTokenRepository)
{
}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->accessTokenRepository->findOneBy(['token' => $accessToken]);
$token = $this->userTokenRepository->findOneBy(['token' => $accessToken]);
if (!$token) {
throw new BadCredentialsException();
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Service\Auth;
enum AccessTokenType: string
{
/**
* An OAuth type represents an Acces Token created via an OAuth flow.
* OAuth Access Tokens represent 3rd party apps acting on behalf of a User.
*/
case OAuth = 'oat';
/**
* A Personal type represents an Access Token created via direct login.
* Personal Access Token represents the User itself.
*/
case Personal = 'pat';
}
+3 -3
View File
@@ -2,8 +2,8 @@
namespace App\Service\Auth;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Entity\UserToken;
class AuthService
{
@@ -24,9 +24,9 @@ class AuthService
return $this->config;
}
public function generateAccessToken(User $user, AccessTokenType $type): AccessToken
public function generateUserToken(User $user, AuthTokenType $type): UserToken
{
$token = new AccessToken;
$token = new UserToken;
$token->setOwnedBy($user);
$token->setToken(sprintf('%s_%s', $type->value, hash(
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Service\Auth;
enum AuthTokenType: string
{
/**
* An OAuth type represents an Authentication Token created via an OAuth flow.
* OAuth Tokens represent 3rd party apps acting on behalf of a User.
*/
case OAuth = 'oat';
/**
* A Personal type represents an Authentication Token created via direct login.
* Personal Access Tokens represent the User itself.
*/
case Personal = 'pat';
}
@@ -4,17 +4,17 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Dto\AccessTokenLoginDto;
use App\Entity\AccessToken;
use App\Dto\UserTokenLoginDto;
use App\Entity\UserToken;
use App\Repository\UserRepository;
use App\Service\Auth\AccessTokenType;
use App\Service\Auth\AuthTokenType;
use App\Service\Auth\AuthService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
class AccessTokenLoginProcessor implements ProcessorInterface
class UserTokenLoginProcessor implements ProcessorInterface
{
public function __construct(
private AuthService $authService,
@@ -24,11 +24,11 @@ class AccessTokenLoginProcessor implements ProcessorInterface
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AccessToken
/**
* @param UserTokenLoginDto $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserToken
{
/** @var AccessTokenLoginDto */
$data;
$user = $this->userRepository->findOneBy(['username' => $data->username]);
if (!$user) {
@@ -39,7 +39,7 @@ class AccessTokenLoginProcessor implements ProcessorInterface
throw new BadCredentialsException();
}
$token = $this->authService->generateAccessToken($user, AccessTokenType::Personal);
$token = $this->authService->generateUserToken($user, AuthTokenType::Personal);
$this->entityManager->persist($token);
$this->entityManager->flush();