<?php
//----------------------------------------------------------------------
// src/Security/Authenticator/TokenAuthenticator.php
//----------------------------------------------------------------------
namespace App\Security\Authenticator;
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\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use App\Entity\APIRest\AccessAPI;
use App\Services\APIRest\Tools\APIResponseTools;
use App\Services\APIRest\Tools\RouteScopeResolver;
use App\Utils\APIError;
class TokenAuthenticator extends AbstractAuthenticator
{
public function __construct(APIResponseTools $apiResponseTools, RouteScopeResolver $routeScopeResolver)
{
$this->apiResponseTools = $apiResponseTools;
$this->routeScopeResolver = $routeScopeResolver;
}
/**
* 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
{
$route = $request->attributes->get('_route');
// Check if it's an API call
if (preg_match("/api_rest_/", $route) !== 1)
{
return false;
}
return true;
}
/**
* Try to authenticate user with request param
*/
public function authenticate(Request $request): Passport
{
// Check headers contain 'X-Auth-Token' or 'Authorization' param
$this->checkHeaders($request);
// Get token sent
$token = $this->getToken($request);
if ($token === null)
{
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
$errorMessage = APIError::AUTHENTICATION_INVALID_TOKEN['errorMessage'];
throw new CustomUserMessageAuthenticationException($errorMessage);
}
// Check User is valid (AcessAPI, token valid, User Active)
$customCredential = new CustomCredentials(
function (string $credentials, UserInterface $user): bool
{
if (!($user instanceof AccessAPI))
{
return false;
}
if ($credentials !== $user->getAccessToken())
{
return false;
}
if ($user->isAccessTokenExpired())
{
return false;
}
if (!$user->getIsActive())
{
return false;
}
return true;
},
// The custom credentials
$token
);
return new Passport(new UserBadge($token), $customCredential);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Scope control is currently in observation mode:
// - routes are not yet annotated with RequiredScopes
// - missing scopes must not block access
$accessAPI = $token->getUser();
if (!($accessAPI instanceof AccessAPI))
{
return null;
}
// Get required scopes for the current route
// The required scopes are defined in the controller method using the RequiredScopes attribute
$requiredScopes = $this->routeScopeResolver->getRequiredScopes($request);
// No route currently uses RequiredScopes; even if it did, we do not block yet.
// This hook is here to prepare future enforcement once scopes are widely used.
if (empty($requiredScopes))
{
return null;
}
// In future we could enforce that at least one required scope is present:
$hasRequiredScope = false;
foreach ($requiredScopes as $requiredScope)
{
if ($accessAPI->hasScope($requiredScope))
{
$hasRequiredScope = true;
break;
}
}
if (!$hasRequiredScope)
{
$apiError = new APIError(APIError::AUTHENTICATION_SCOPE_FORBIDDEN);
return $this->apiResponseTools->unauthorizedResponse($apiError);
}
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// Prevent warning for size overload
ob_get_contents();
ob_end_clean();
// Avoid symfony error caused by CustomCredentials return false
if ($exception instanceof BadCredentialsException)
{
$apiError = new APIError(APIError::AUTHENTICATION_FAILED);
}
else
{
// Classic case
$apiError = new APIError(APIError::AUTHENTICATION_FAILED, $exception->getMessage());
}
return $this->apiResponseTools->unauthorizedResponse($apiError);
}
// ----- Custom methods ----- //
/**
* Check headers has 'X-Auth-Token' or 'Authorization' param (Else throw Exception to trigger 'onAuthenticationFailure' method)
* @throws CustomUserMessageAuthenticationException
*/
protected function checkHeaders(Request $request)
{
if (!($request->headers->has('X-Auth-Token') || $request->headers->has('Authorization')))
{
$errorMessage = APIError::AUTHENTICATION_REQUIRED['errorMessage'];
throw new CustomUserMessageAuthenticationException($errorMessage);
}
}
protected function getToken(Request $request): ?string
{
if ($request->headers->has('X-Auth-Token'))
{
return $request->headers->get('X-Auth-Token');
}
if ($request->headers->has('Authorization') && stristr($request->headers->get('Authorization'), 'Bearer ') != false)
{
return str_ireplace('Bearer ', '', $request->headers->get('Authorization'));
}
// Should never happen
return null;
}
}