API token auth using Guard implementation added.
This commit is contained in:
parent
c5d2f68c57
commit
94e4ffe328
|
@ -1,11 +1,13 @@
|
||||||
security:
|
security:
|
||||||
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
||||||
providers:
|
providers:
|
||||||
app_db_provider:
|
default_provider:
|
||||||
entity:
|
entity:
|
||||||
class: App\Entity\User
|
class: App\Entity\User
|
||||||
property: username
|
property: username
|
||||||
manager_name: default
|
manager_name: default
|
||||||
|
api_token_provider:
|
||||||
|
id: App\Security\ApiTokenUserProvider
|
||||||
encoders:
|
encoders:
|
||||||
App\Entity\User:
|
App\Entity\User:
|
||||||
algorithm: 'argon2i'
|
algorithm: 'argon2i'
|
||||||
|
@ -20,10 +22,13 @@ security:
|
||||||
pattern: ^/api/
|
pattern: ^/api/
|
||||||
stateless: true
|
stateless: true
|
||||||
anonymous: true
|
anonymous: true
|
||||||
|
simple_preauth:
|
||||||
|
authenticator: App\Security\ApiTokenAuthenticator
|
||||||
|
provider: api_token_provider
|
||||||
main:
|
main:
|
||||||
pattern: ^/
|
pattern: ^/
|
||||||
anonymous: ~
|
anonymous: ~
|
||||||
provider: app_db_provider
|
provider: default_provider
|
||||||
form_login:
|
form_login:
|
||||||
login_path: user_login
|
login_path: user_login
|
||||||
check_path: user_login
|
check_path: user_login
|
||||||
|
@ -40,7 +45,8 @@ security:
|
||||||
# Easy way to control access for large sections of your site
|
# Easy way to control access for large sections of your site
|
||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/api/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
- { path: ^/api/v1/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||||
|
- { path: ^/api/, roles: ROLE_USER }
|
||||||
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||||
- { path: /login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
- { path: /login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||||
- { path: ^/, roles: ROLE_USER }
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
|
|
@ -31,6 +31,15 @@ user_logout:
|
||||||
path: /logout
|
path: /logout
|
||||||
|
|
||||||
# API
|
# API
|
||||||
|
api_login:
|
||||||
|
path: /api/v1/login
|
||||||
|
controller: App\Api\V1\Controller\SecurityController::login
|
||||||
|
defaults:
|
||||||
|
_format: json
|
||||||
|
requirements:
|
||||||
|
method: POST
|
||||||
|
_format: json
|
||||||
|
|
||||||
api_v1_torrents:
|
api_v1_torrents:
|
||||||
path: /api/v1/torrents
|
path: /api/v1/torrents
|
||||||
controller: App\Api\V1\Controller\TorrentController::search
|
controller: App\Api\V1\Controller\TorrentController::search
|
||||||
|
|
19
src/Api/V1/Controller/AbstractApiController.php
Normal file
19
src/Api/V1/Controller/AbstractApiController.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Controller;
|
||||||
|
|
||||||
|
use App\Api\V1\DTO\ApiResponse;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||||
|
use Symfony\Component\HttpFoundation\{JsonResponse, Response};
|
||||||
|
|
||||||
|
abstract class AbstractApiController extends Controller
|
||||||
|
{
|
||||||
|
protected const DEFAULT_SERIALIZER_GROUPS = ['api_v1'];
|
||||||
|
|
||||||
|
protected function createJsonResponse($data, array $groups = [], int $code = Response::HTTP_OK, string $message = null, string $status = ''): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->json(new ApiResponse($data, $code, $message, $status), $code, [], [
|
||||||
|
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,$groups),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
13
src/Api/V1/Controller/SecurityController.php
Normal file
13
src/Api/V1/Controller/SecurityController.php
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Controller;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||||
|
|
||||||
|
class SecurityController extends AbstractApiController
|
||||||
|
{
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// @todo implement login procedure
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,22 +2,18 @@
|
||||||
|
|
||||||
namespace App\Api\V1\Controller;
|
namespace App\Api\V1\Controller;
|
||||||
|
|
||||||
use App\Api\V1\DTO\ApiResponse;
|
|
||||||
use App\Api\V1\DTO\ListPage;
|
use App\Api\V1\DTO\ListPage;
|
||||||
use App\Magnetico\Entity\Torrent;
|
use App\Magnetico\Entity\Torrent;
|
||||||
use App\Magnetico\Repository\TorrentRepository;
|
use App\Magnetico\Repository\TorrentRepository;
|
||||||
use Pagerfanta\Adapter\DoctrineORMAdapter;
|
use Pagerfanta\Adapter\DoctrineORMAdapter;
|
||||||
use Pagerfanta\Pagerfanta;
|
use Pagerfanta\Pagerfanta;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||||
use Symfony\Component\HttpFoundation\{Request, Response};
|
|
||||||
|
|
||||||
class TorrentController extends Controller
|
class TorrentController extends AbstractApiController
|
||||||
{
|
{
|
||||||
private const DEFAULT_SERIALIZER_GROUPS = ['api_v1'];
|
|
||||||
|
|
||||||
private const PER_PAGE = 20;
|
private const PER_PAGE = 20;
|
||||||
|
|
||||||
public function search(Request $request, TorrentRepository $repo): Response
|
public function search(Request $request, TorrentRepository $repo): JsonResponse
|
||||||
{
|
{
|
||||||
$query = $request->query->get('query', '');
|
$query = $request->query->get('query', '');
|
||||||
$page = (int) $request->query->get('page', '1');
|
$page = (int) $request->query->get('page', '1');
|
||||||
|
@ -29,16 +25,12 @@ class TorrentController extends Controller
|
||||||
->setMaxPerPage(self::PER_PAGE)
|
->setMaxPerPage(self::PER_PAGE)
|
||||||
;
|
;
|
||||||
|
|
||||||
return $this->json(new ApiResponse(ListPage::createFromPager($pager)),Response::HTTP_OK, [], [
|
return $this->createJsonResponse(ListPage::createFromPager($pager), ['api_v1_search']);
|
||||||
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_search']),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Torrent $torrent): Response
|
public function show(Torrent $torrent): JsonResponse
|
||||||
{
|
{
|
||||||
return $this->json(new ApiResponse($torrent), Response::HTTP_OK, [], [
|
return $this->createJsonResponse($torrent, ['api_v1_show'], JsonResponse::HTTP_OK,null, '');
|
||||||
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_show']),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
57
src/Entity/ApiToken.php
Normal file
57
src/Entity/ApiToken.php
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Table(name="api_tokens", schema="users")
|
||||||
|
* @ORM\Entity(repositoryClass="App\Repository\ApiTokenRepository", readOnly=true)
|
||||||
|
*/
|
||||||
|
class ApiToken
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var User
|
||||||
|
*
|
||||||
|
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||||
|
* @ORM\JoinColumn(name="user_id", nullable=false)
|
||||||
|
*/
|
||||||
|
private $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*
|
||||||
|
* @ORM\Id()
|
||||||
|
* @ORM\Column(name="key", type="string", length=32)
|
||||||
|
*/
|
||||||
|
private $key;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \DateTime
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="created_at", type="datetime")
|
||||||
|
*/
|
||||||
|
private $createdAt;
|
||||||
|
|
||||||
|
public function __construct(User $user)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->key = md5(random_bytes(100));
|
||||||
|
$this->createdAt = new \DateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKey(): string
|
||||||
|
{
|
||||||
|
return $this->key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTime
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
32
src/Repository/ApiTokenRepository.php
Normal file
32
src/Repository/ApiTokenRepository.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\{ApiToken, User};
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||||
|
|
||||||
|
class ApiTokenRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(RegistryInterface $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ApiToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(ApiToken $token): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findUserByTokenKey(string $tokenKey): ?User
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('at');
|
||||||
|
$qb
|
||||||
|
->select('at.user')
|
||||||
|
->where('at.key = :tokenKey')
|
||||||
|
->setParameter('tokenKey', $tokenKey)
|
||||||
|
;
|
||||||
|
|
||||||
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
}
|
85
src/Security/ApiTokenAuthenticator.php
Normal file
85
src/Security/ApiTokenAuthenticator.php
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Api\V1\DTO\ApiResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\{PreAuthenticatedToken, TokenInterface};
|
||||||
|
use Symfony\Component\Security\Core\Exception\{AuthenticationException, BadCredentialsException, CustomUserMessageAuthenticationException};
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\{AuthenticationFailureHandlerInterface, SimplePreAuthenticatorInterface};
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
|
class ApiTokenAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
|
||||||
|
{
|
||||||
|
public const TOKEN_HEADER = 'api-token';
|
||||||
|
|
||||||
|
/** @var SerializerInterface */
|
||||||
|
private $serializer;
|
||||||
|
|
||||||
|
public function __construct(SerializerInterface $serializer)
|
||||||
|
{
|
||||||
|
$this->serializer = $serializer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes request data and creates token which will be ready to auth check */
|
||||||
|
public function createToken(Request $request, $providerKey)
|
||||||
|
{
|
||||||
|
if (!($tokenKey = $request->headers->get(self::TOKEN_HEADER))) {
|
||||||
|
throw new BadCredentialsException(sprintf('\'%s\' is invalid or not defined', self::TOKEN_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PreAuthenticatedToken(
|
||||||
|
'anon.',
|
||||||
|
$tokenKey,
|
||||||
|
$providerKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
|
||||||
|
{
|
||||||
|
if (!$userProvider instanceof ApiTokenUserProvider) {
|
||||||
|
throw new \InvalidArgumentException(sprintf(
|
||||||
|
'The user provider for providerKey = \'%s\' must be an instance of %s, %s given.',
|
||||||
|
$providerKey,
|
||||||
|
ApiTokenUserProvider::class,
|
||||||
|
get_class($userProvider)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiTokenKey = $token->getCredentials();
|
||||||
|
|
||||||
|
$user = $userProvider->loadUserByUsername($apiTokenKey);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new CustomUserMessageAuthenticationException(sprintf(
|
||||||
|
'API token \'%s\' does not exist.', $apiTokenKey
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PreAuthenticatedToken(
|
||||||
|
$user,
|
||||||
|
$apiTokenKey,
|
||||||
|
$providerKey,
|
||||||
|
$user->getRoles()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
|
||||||
|
{
|
||||||
|
// @todo Decouple with App\Api\V1\DTO
|
||||||
|
$json = $this->serializer->serialize(
|
||||||
|
new ApiResponse(null, JsonResponse::HTTP_UNAUTHORIZED, $exception->getMessage()),
|
||||||
|
'json',
|
||||||
|
['groups' => ['api_v1']]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResponse($json, JsonResponse::HTTP_UNAUTHORIZED,[], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function supportsToken(TokenInterface $token, $providerKey)
|
||||||
|
{
|
||||||
|
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
|
||||||
|
}
|
||||||
|
}
|
39
src/Security/ApiTokenUserProvider.php
Normal file
39
src/Security/ApiTokenUserProvider.php
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\ApiTokenRepository;
|
||||||
|
use Symfony\Component\Security\Core\Exception\{UnsupportedUserException, UsernameNotFoundException};
|
||||||
|
use Symfony\Component\Security\Core\User\{UserInterface, UserProviderInterface};
|
||||||
|
|
||||||
|
class ApiTokenUserProvider implements UserProviderInterface
|
||||||
|
{
|
||||||
|
/** @var ApiTokenRepository */
|
||||||
|
private $userRepo;
|
||||||
|
|
||||||
|
public function __construct(ApiTokenRepository $userRepo)
|
||||||
|
{
|
||||||
|
$this->userRepo = $userRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadUserByUsername($username): User
|
||||||
|
{
|
||||||
|
if (null === $user = $this->userRepo->findUserByTokenKey($username)) {
|
||||||
|
throw new UsernameNotFoundException(sprintf('Token \'%s\' is not found.', $username));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshUser(UserInterface $user)
|
||||||
|
{
|
||||||
|
throw new UnsupportedUserException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsClass($class): bool
|
||||||
|
{
|
||||||
|
return User::class === $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue