API token auth using Guard implementation added.

This commit is contained in:
Alexey Skobkin 2018-06-26 00:56:32 +03:00
parent c5d2f68c57
commit 94e4ffe328
9 changed files with 269 additions and 17 deletions

View file

@ -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 }

View file

@ -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

View 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),
]);
}
}

View 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
}
}

View file

@ -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
View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}