I'm building an API application with Symfony 6.2 and trying to use Access Tokens for stateless authentication. As suggested in symfony docs I have implemented AccessTokenHandler class
<?php
# src\Security\AccessTokenHandler.php
namespace App\Security;
use App\Repository\AccessTokenRepository;
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 $repository
) {
}
public function getUserBadgeFrom( string $accessToken ): UserBadge {
// e.g. query the "access token" database to search for this token
$accessToken = $this->repository->findValidToken($accessToken);
if ( NULL === $accessToken || !$accessToken->isValid() ) {
throw new BadCredentialsException( 'Invalid credentials.' );
}
/* DEBUG: UNCOMMENT FOR DEBUG
dump($accessToken);die;
/* DEBUG /**/
// and return a UserBadge object containing the user identifier from the found token
return new UserBadge( $accessToken->getUserId() );
}
}
and configured my security.yaml file
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
access_token_provider:
entity:
class: App\Entity\AccessToken
property: tokenValue
firewalls:
main:
lazy: true
provider: access_token_provider
stateless: true
pattern: ^/api
access_token:
token_extractors: header
token_handler: App\Security\AccessTokenHandler
access_control:
- { path: ^/api, roles: ROLE_ADMIN }
In the database there is the only user in User table and related token in AccessToken table.
To test the above I'm using Postman to send a GET request to https://my-host/api/test-page with the following header:
Authorization: Bearer 59e4fb3f9c10e0fe570d486462ab417a
It gets into AccessTokenHandler::getUserBadgeFrom as expected and returns the correct token with user (see the commented debug code on AccessTokenHandler:23).
Yet, the request returns status code 401 Unauthorized with WWW-Authenticate: Bearer error="invalid_token",error_description="Invalid credentials." header.
Related
I know there are a lot of threads on this subject, but none of them helped me...
I am working on an application on which you have to send an access token in the headers for each request.
I manage this security with Guard.
For my tests, when I send a false token, or when I do not send it, the start() or onAuthenticationFailure() method must be called as appropriate.
But it does not work. I have the same error every time.
Looks like these methods are never called.
No authorization sent
GET /BileMo/web/app_dev.php/api/products/2 HTTP/1.1
Host: localhost:8888
Content-Type: application/json
{
"message": "Username could not be found."
}
Invalid access token
GET /BileMo/web/app_dev.php/api/products/2 HTTP/1.1
Host: localhost:8888
Content-Type: application/json
Authorization: *Fake Facebook Token*
{
"message": "Username could not be found."
}
instead of:
{
"message": "Authorization required"
}
or
{
"message": "The facebook access token is wrong!"
}
With a correct access token, requests are returned to the user correctly.
Example of a request :
GET /BileMo/web/app_dev.php/api/products/2 HTTP/1.1
Host: localhost:8888
Content-Type: application/json
Authorization: *Facebook Token*
Here are the important parts of my code:
security.yml
security:
encoders:
FOS\UserBundle\Model\UserInterface: sha512
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_USER
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
api_key_user_provider:
entity:
class: FacebookTokenBundle:User
property: facebook_access_token
firewalls:
api:
pattern: ^/api
stateless: true
anonymous: false
guard:
authenticators:
- AppBundle\Security\FacebookAuthenticator
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
form_login:
provider: fos_userbundle
csrf_token_generator: security.csrf.token_manager
login_path: /login
check_path: /login_check
oauth:
resource_owners:
facebook: "/login/check-facebook"
login_path: /login
failure_path: /login
oauth_user_provider:
#this is my custom user provider, created from FOSUBUserProvider - will manage the
#automatic user registration on your site, with data from the provider (facebook. google, etc.)
service: my_user_provider
logout: true
anonymous: true
login:
pattern: ^/login$
security: false
remember_me:
secret: "%secret%"
lifetime: 31536000 # 365 days in seconds
path: /
domain: ~ # Defaults to the current domain from $_SERVER
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin/, role: ROLE_ADMIN }
- { path: ^/api, role: ROLE_USER }
FacebookAuthenticator.php
namespace AppBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class FacebookAuthenticator extends AbstractGuardAuthenticator
{
public function __construct(EntityManager $em)
{
$this->em = $em;
}
/**
* Called when authentication is needed, but it's not sent
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$data = array(
// you might translate this message
'message' => 'Authentication Required'
);
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
/**
* Called on every request. Return whatever credentials you want to
* be passed to getUser(). Returning null will cause this authenticator
* to be skipped.
*/
public function getCredentials(Request $request)
{
if (!$token = $request->headers->get('Authorization')) {
// No token?
$token = null;
}
// What you return here will be passed to getUser() as $credentials
return array(
'token' => $token,
);
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$user = $this->em->getRepository('FacebookTokenBundle:User')
->findOneBy(array('facebook_access_token' => $credentials));
return $user;
}
public function checkCredentials($credentials, UserInterface $user)
{
if ($user->getFacebookAccessToken() === $credentials['token']) {
return true;
}
return new JsonResponse(array('message' => 'The facebook access token is wrong!', Response::HTTP_FORBIDDEN));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$data = array(
'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
);
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
}
public function supportsRememberMe()
{
return false;
}
}
This behaviour is expected. AbstractGuardAuthenticator has too generic interface, and you need to tailor it to your needs, if you want.
For example, to have error "Authorization required" - you may throw AuthenticationException inside getCredentials() methods. Exception will be catch in symfony core and method start() will be called finally.
public function getCredentials(Request $request)
{
if (!$request->headers->has('Authorization')) {
throw new AuthenticationException();
}
...
}
Method onAuthenticationFailure() is usually used to redirect user to login page in case of wrong credentials. In case of API key in header this functionality is not needed. Also in current implementation how to separate, when "API key not correct" and when "user is not found"?
The above answer is somewhat true with a few corrections:
Any exceptions thrown from within the authenticator (guard) itself will trigger onAuthenticationFailure()
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
{
return new JsonResponse(['message' => 'Forbidden'], Response::HTTP_FORBIDDEN);
}
public function start(Request $request, AuthenticationException $authException = null): JsonResponse
{
return new JsonResponse(['message' => 'Authentication Required'], Response::HTTP_UNAUTHORIZED);
}
The method start() is called when, for example, you throw an AccessDeniedException from within your app like in the controller. Maybe a good use case for that would be, say, you want to blacklist one specific user for one specific route and you don't want to fill up your guard authenticator with unneeded bloat.
/**
* #Route("/something-special")
*/
public function somethingSpecial()
{
if ($this->getUser()->getUsername() === 'a_banned_user') {
throw new AccessDeniedException();
}
// ...
}
Then test it with:
$ curl -H "Auth-Header-Name: the_auth_token" http://site.local/something-special
{"message":"Authentication Required"}
But, on the other hand, if you throw an exception due to a missing token header, then onAuthenticationFailure() will run instead:
public function getCredentials(Request $request): array
{
if (!$request->headers->has('Authorization')) {
throw new AuthenticationException('Authentication header missing.');
}
// ...
}
Then test it with (note: the AuthenticationException message is ignored in onAuthenticationFailure() and just returns a generic "Forbidden" message as you can see above):
$ curl http://site.local/something-special
{"message":"Forbidden"}
Functional test of a FOSRestBundle app fails with a 401 code while Postman, with identical credentials, passes. Test db is sqlite, prod db is MySQL. Stored passwords are identical.
The test:
public function testBasicAuth() {
$client = static::createClient([], ['PHP_AUTH_USER' => 'admin',
'PHP_AUTH_PW' => '123Abc',]);
$crawler = $client->request(
'GET', '/basic/get_user/bborko#bogus.info'
);
$this->assertSame(200, $client->getResponse()->getStatusCode());
}
The failure:
Failed asserting that 401 is identical to 200.
The controller:
/**
* #Route("/get_user/{email}", name="basic_get_user")
*
* #return View
*/
public function getUserAction($email) {
$em = $this->getDoctrine()->getManager();
$data = $em->getRepository('AppBundle:Member')->findBy(['email' => $email]);
if (!$data) {
throw $this->createNotFoundException('Unable to find user entity');
}
$view = $this->view($data, 200)
->setTemplate("AppBundle:Users:getUsers.html.twig")
->setTemplateVar('user')
;
return $this->handleView($view);
}
Edit:
Note: The app also has an API/API-key route that tests successfully.
security.yml:
...
providers:
fos_userbundle:
id: fos_user.user_provider.username
api_key_user_provider:
id: api_key_user_provider
...
firewalls:
api:
pattern: ^/api/
stateless: true
simple_preauth:
authenticator: apikey_authenticator
provider: api_key_user_provider
basic:
pattern: ^/basic/
http_basic:
provider: fos_userbundle
anonymous: false
...
access_control:
...
- { path: ^/api/, role: ROLE_ADMIN }
- { path: ^/basic/, role: ROLE_ADMIN }
Postman:
If you switch in postman in tab "Headers" you'll should see the header "Authorization: {some token}". It is send for authorization user by Basic Auth. You can get this toke by function base64_encode("username:password") (colon is require). Try authorize your user in test by this token
I have a Symfony 2.7.6 project with custom Simple Form authentication provider and support for remember me functionality as well as impersonalization feature. Everything works as expected.
However, I want to introduce another authentication provider that will allow requests regardless of session state using two HTTP headers for authentication (e.g. API-Client-Id and API-Client-Token) for third-party applications.
I've created a Simple Pre-Auth authentication provider that validates these header fields and creates authentication token with empty User instance on success.
However, it looks like Symfony is trying to remember those API authentications using session, so I'm getting the following error on the second request: "You cannot refresh a user from the EntityUserProvider that does not contain an identifier. The user object has to be serialized with its own identifier mapped by Doctrine.".
I can set stateless: true flag in my firewall configuration to disable session support, but it will disable it for both auth providers.
SO, how do I preserve existing functionality with my Simple Form authenticator and yet create another layer of authentication to be used for single stateless API requests?
I'm not sure if my approach is conceptually correct. I will gladly accept any suggestions and will provide any relevant information on first request.
Here's my security.yml config:
security:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
anonymous: ~
form_login:
login_path: app.login
check_path: app.session.sign_in
username_parameter: username
password_parameter: password
success_handler: app.security.login_handler
failure_handler: app.security.login_handler
require_previous_session: false
logout:
path: app.session.sign_out
invalidate_session: false
success_handler: app.security.logout_success_handler
# Simple form auth provider
simple_form:
authenticator: app.security.authenticator.out_service
# Token provider
simple_preauth:
authenticator: app.security.authenticator.api_client
remember_me:
name: "%app.session.remember_me.name%"
key: "%secret%"
lifetime: 1209600 # 14 days
path: /
domain: ~
always_remember_me: true
switch_user: { role: ROLE_ADMIN }
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/recover-password, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /, roles: IS_AUTHENTICATED_REMEMBERED }
providers:
main:
entity:
class: App\AppBundle\Model\User
property: id
encoders:
App\AppBundle\Model\User: plaintext
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_ACTIVE]
ROLE_API_CLIENT: ~
ROLE_USER: ~
ROLE_ACTIVE: ~
ApiClientAuthenticator.php:
<?php
namespace App\AppBundle\Security;
use Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use App\AppBundle\Model\User;
class ApiClientAuthenticator implements SimplePreAuthenticatorInterface
{
/** #var LoggerInterface */
protected $logger;
/** #var array */
protected $clients;
/**
* #param array $clients
*/
public function __construct(array $clients)
{
$this->clients = $clients;
}
public function createToken(Request $request, $providerKey)
{
$clientId = $request->headers->get('Api-Client-Id');
$clientSecret = $request->headers->get('Api-Client-Secret');
if (!$clientId || !$clientSecret) {
return null;
}
return new PreAuthenticatedToken(
'anon.',
[$clientId, $clientSecret],
$providerKey
);
}
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
list ($clientId, $clientSecret) = $token->getCredentials();
$foundClient = null;
foreach ($this->clients as $client) {
if ($client['id'] == $clientId) {
if ($client['secret'] == $clientSecret) {
$foundClient = $client;
break;
}
}
}
if (!$foundClient) {
throw new AuthenticationException;
}
$user = new User;
$user->setApiClient(true);
return new PreAuthenticatedToken(
$user,
$foundClient,
$providerKey,
['ROLE_API_CLIENT']
);
}
public function supportsToken(TokenInterface $token, $providerKey)
{
return ($token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey);
}
}
I'm developing a RESTful web service in Symfony2 with FOSRest and FOSOauthServer bundles (... and many others). My problem is that with an access token of other user, the api gives response instead of a 403 status code. For example:
I have two users stored on database
userA with tokenA
userB with tokenB
Example Request
http://example.com/api/v1/userA/products?access_token=tokenB
Current Response
{
products: {
0: { ... }
1: { ... }
}
}
But I'm requesting products of user A with an access token of user B. How could I check if access token provided is of the products' owner??
My security.yml file:
security:
encoders:
FOS\UserBundle\Model\UserInterface: sha512
role_hierarchy:
MY_ROLE:
# ...
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_SONATA_ADMIN, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
SONATA:
- ROLE_SONATA_PAGE_ADMIN_PAGE_EDIT
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
firewalls:
admin:
pattern: /admin(.*)
context: user
form_login:
provider: fos_userbundle
csrf_provider: form.csrf_provider
login_path: /admin/login
use_forward: false
check_path: /admin/login_check
failure_path: null
logout:
path: /admin/logout
anonymous: true
# FOSOAuthBundle and FOSRestBundle
oauth_token:
pattern: ^/oauth/v2/token
security: false
# oauth_authorize: commented because there are not oauth login form on this app
# pattern: ^/oauth/v2/auth
# Add your favorite authentication process here
api:
pattern: ^/api
fos_oauth: true
stateless: true
anonymous: false
# This firewall is used to handle the public login area
# This part is handled by the FOS User Bundle
main:
# ...
access_control:
# ...
# API (FOSRestBundle and FOSOAuthBundle)
- { path: ^/api, roles: [IS_AUTHENTICATED_FULLY] }
My routing.yml on ApiBundle
# API Endpoints
app_api_user_get_products:
pattern: /{username}/products
defaults: { _controller: ApiBundle:User:getProducts, _format: json }
methods: GET
My UserController.php
<?php
namespace App\ApiBundle\Controller;
Use App\MainBundle\Entity\Product;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// ... more use statments
class UserController extends ApiController {
/**
* List user's products.
*
* #ApiDoc(
* resource = true,
* description="This method must have the access_token parameter. The parameters limit and offset are optional.",
* filters={
* {"name"="access_token", "dataType"="string", "required"="true"},
* {"name"="offset", "dataType"="integer", "default"="0", "required"="false"},
* {"name"="limit", "dataType"="integer", "default"="250", "required"="false"}
* },
* )
*
* #Annotations\QueryParam(name="offset", requirements="\d+", nullable=true, description="Offset from which to start listing products.")
* #Annotations\QueryParam(name="limit", requirements="\d+", default="500", description="How many products to return.")
*
* #Annotations\View()
*
* #param User $user the request object
* #param ParamFetcherInterface $paramFetcher param fetcher service
*
* #return array
*/
public function getProductsAction(User $user, ParamFetcherInterface $paramFetcher, Request $request) {
// $offset = $paramFetcher->get('offset');
// $offset = null == $offset ? 0 : $offset;
// $limit = $paramFetcher->get('limit');
try {
// estructure and exclude fields strategy http://jmsyst.com/libs/serializer/master/cookbook/exclusion_strategies
$data = array('products' => array());
foreach ($user->getCatalog() as $p) {
if ($p->getAvailable() == true) {
$product = $p->getProduct();
$data['products'][] = array(
'id' => $product->getId(),
'reference' => $product->getReference(),
'brand' => $product->getBrand(),
'description' => $product->getDescription(),
// ...
);
}
}
} catch (\Exception $e) {
throw new HttpException(Codes::HTTP_INTERNAL_SERVER_ERROR, $e->getTraceAsString());
}
// New view
$view = new View($data);
$view->setFormat('json');
return $this->handleView($view);
}
}
Thank you very much for the help!
I've found the solution. It's easy, just I've added the following code in my rest controller and the configuration parameters on app/config.yml
UserController.php
...
public function getProductsAction(User $user, ParamFetcherInterface $paramFetcher, Request $request) {
// Check if the access_token belongs to the user making the request
$requestingUser = $this->get('security.context')->getToken()->getUser();
if (!$requestingUser || $requestingUser !== $user) {
throw new AccessDeniedHttpException();
}
...
~/app/config.yml
# FOSRestBundle
fos_rest:
routing_loader:
default_format: json
param_fetcher_listener: true
view:
view_response_listener: force
access_denied_listener: # I've added this
# all requests using the 'json' format will return a 403 on an access denied violation
json: true
You can also make it simpler using #Security annotation in Symfony >= 2.4 . In your case it'll look like
/**
* #Security("user.getId() == userWithProducts.getId()")
*/
and the action header:
...
public function getProductsAction(User $userWithProducts, ParamFetcherInterface $paramFetcher, Request $request) {
...
I have my security file configured as follows:
security:
...
pattern: ^/[members|admin]
form_login:
check_path: /members/auth
login_path: /public/login
failure_forward: false
failure_path: null
logout:
path: /public/logout
target: /
Currently if I access the members url without authenticating it redirects me to /public/login but I dont want it to redirect. I'm mainly responding with json on my controllers so I just want to show a warning on the restricted url such as {"error": "Access denied"}. If I take out the login_path: /public/login code it redirects to a default url /login. How do I do to stop it from redirecting?
You need to create a Listener and then trigger your response. My solution is based on - https://gist.github.com/xanf/1015146
Listener Code --
namespace Your\NameSpace\Bundle\Listener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
class AjaxAuthenticationListener
{
/**
* Handles security related exceptions.
*
* #param GetResponseForExceptionEvent $event An GetResponseForExceptionEvent instance
*/
public function onCoreException(GetResponseForExceptionEvent $event)
{
$exception = $event->getException();
$request = $event->getRequest();
if ($request->isXmlHttpRequest()) {
if ($exception instanceof AuthenticationException || $exception instanceof AccessDeniedException || $exception instanceof AuthenticationCredentialsNotFoundException) {
$responseData = array('status' => 401, 'msg' => 'User Not Authenticated');
$response = new JsonResponse();
$response->setData($responseData);
$response->setStatusCode($responseData['status']);
$event->setResponse($response);
}
}
}
}
You need to create a service for the listener --
e_ent_int_baems.ajaxauthlistener:
class: Your\NameSpace\Bundle\Listener\AjaxAuthenticationListener
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onCoreException, priority: 1000 }
You can do like I did:
in security.yml
firewalls:
administrators:
pattern: ^/
form_login:
check_path: _security_check
login_path: _security_login
logout: true
security: true
anonymous: true
access_denied_url: access_denied
in routing.yml
access_denied:
path: /error403
defaults :
_controller: FrameworkBundle:Template:template
template: 'DpUserBundle:Static:error403.html.twig'
simply add to firewall section *access_denied_url* param
See this page for the full security.yml configuration reference. Also, this is an even better reference with explanations of each key.
I'd suggest creating your own listener class to handle returning JSON when a User needs to login. Example: https://gist.github.com/1015146