diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 1baad65..106c86a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,11 +1,13 @@ security: # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: - app_db_provider: + default_provider: entity: class: App\Entity\User property: username manager_name: default + api_token_provider: + id: App\Security\ApiTokenUserProvider encoders: App\Entity\User: algorithm: 'argon2i' @@ -20,10 +22,13 @@ security: pattern: ^/api/ stateless: true anonymous: true + simple_preauth: + authenticator: App\Security\ApiTokenAuthenticator + provider: api_token_provider main: pattern: ^/ anonymous: ~ - provider: app_db_provider + provider: default_provider form_login: login_path: user_login check_path: user_login @@ -40,7 +45,8 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used 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: /login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/, roles: ROLE_USER } diff --git a/config/routes.yaml b/config/routes.yaml index a386f24..20ddb7f 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -31,6 +31,15 @@ user_logout: path: /logout # 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: path: /api/v1/torrents controller: App\Api\V1\Controller\TorrentController::search diff --git a/src/Api/V1/Controller/AbstractApiController.php b/src/Api/V1/Controller/AbstractApiController.php new file mode 100644 index 0000000..fb0a90c --- /dev/null +++ b/src/Api/V1/Controller/AbstractApiController.php @@ -0,0 +1,19 @@ +json(new ApiResponse($data, $code, $message, $status), $code, [], [ + 'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,$groups), + ]); + } +} \ No newline at end of file diff --git a/src/Api/V1/Controller/SecurityController.php b/src/Api/V1/Controller/SecurityController.php new file mode 100644 index 0000000..46c3a6f --- /dev/null +++ b/src/Api/V1/Controller/SecurityController.php @@ -0,0 +1,13 @@ +query->get('query', ''); $page = (int) $request->query->get('page', '1'); @@ -29,16 +25,12 @@ class TorrentController extends Controller ->setMaxPerPage(self::PER_PAGE) ; - return $this->json(new ApiResponse(ListPage::createFromPager($pager)),Response::HTTP_OK, [], [ - 'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_search']), - ]); + return $this->createJsonResponse(ListPage::createFromPager($pager), ['api_v1_search']); } - public function show(Torrent $torrent): Response + public function show(Torrent $torrent): JsonResponse { - return $this->json(new ApiResponse($torrent), Response::HTTP_OK, [], [ - 'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_show']), - ]); + return $this->createJsonResponse($torrent, ['api_v1_show'], JsonResponse::HTTP_OK,null, ''); } diff --git a/src/Entity/ApiToken.php b/src/Entity/ApiToken.php new file mode 100644 index 0000000..61ef9d1 --- /dev/null +++ b/src/Entity/ApiToken.php @@ -0,0 +1,57 @@ +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; + } +} \ No newline at end of file diff --git a/src/Repository/ApiTokenRepository.php b/src/Repository/ApiTokenRepository.php new file mode 100644 index 0000000..0a7847b --- /dev/null +++ b/src/Repository/ApiTokenRepository.php @@ -0,0 +1,32 @@ +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(); + } +} \ No newline at end of file diff --git a/src/Security/ApiTokenAuthenticator.php b/src/Security/ApiTokenAuthenticator.php new file mode 100644 index 0000000..7610bd4 --- /dev/null +++ b/src/Security/ApiTokenAuthenticator.php @@ -0,0 +1,85 @@ +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; + } +} \ No newline at end of file diff --git a/src/Security/ApiTokenUserProvider.php b/src/Security/ApiTokenUserProvider.php new file mode 100644 index 0000000..85f01b4 --- /dev/null +++ b/src/Security/ApiTokenUserProvider.php @@ -0,0 +1,39 @@ +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; + } + +} \ No newline at end of file