meandeveloper111
meandeveloper111

Reputation: 99

Symfony 3.2: Authenticate users through different properties

I want to achieve the following:

In my installation there are two bundles, ApiBundle and BackendBundle. Users are defined in BackendBundle, though I could put them in a UserBundle later.

ApiBundle basically provides a controller with api methods like for example getSomething().

BackendBundle has the user entities, services and some views like a login form and a backend view. From the backend controller I would want to access certain api methods.

Other api methods will be requested from outside. Api methods will be requested through curl.

I would want to have different users for both purposes. The User class implements UserInterface and has properties like $username, $password and $apiKey.

Now basically I want to provide an authentication method through login form with username and password, and another authentication method for api calls through curl from outside, that only will require the apiKey.

In both cases, the authenticated user then should have access to different ressources.

My security.yml so far looks like this:

providers:
    chain_provider:
        chain:
            providers: [db_username, db_apikey]
    db_username:
        entity:
            class: BackendBundle:User
            property: username
    db_apikey:
        entity:
            class: BackendBundle:User
            property: apiKey

encoders:
    BackendBundle\Entity\User:
        algorithm: bcrypt
        cost: 12

firewalls:
    dev:
        pattern: ^/(_(profiler|wdt)|css|images|js)/
        security: false
    main:
        anonymous: ~
        form_login:
            login_path: login
            check_path: login
            default_target_path: backend
            csrf_token_generator: security.csrf.token_manager
        logout:
            path:   /logout
            target: /login
        provider: chain_provider

access_control:
    - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - { path: ^/api, roles: ROLE_API }
    - { path: ^/backend, roles: ROLE_BACKEND } 

Question 1: How can I achieve that users from the same entity can authenticate differently and access certain ressources? The desired behaviour is authentication with username/password OR only apikey.

Question 2: How can I achieve, that api methods return a json if the requester is not authenticated properly, instead of returning the view for the login form? Eg. I want to return something like { 'error': 'No access' } instead of the html for the login form if someone requests /api/getSomething and of course I want to show the login form if someone requests /backend/someroute.

Every help is very much appreciated! :)


The symfony docs say:

The main job of a firewall is to configure how your users will authenticate. Will they use a login form? HTTP basic authentication? An API token? All of the above?

I think my question basically is, how can I have login form AND api token authentication at the same time.

So maybe, I need something like this: http://symfony.com/doc/current/security/guard_authentication.html#frequently-asked-questions

Upvotes: 1

Views: 1213

Answers (1)

Jan Rydrych
Jan Rydrych

Reputation: 2258

Question 1: When you want to authenticate users by apiKey only, then best possible solution would be implement own User provider. The solution is well decribed in the Symfony doc: http://symfony.com/doc/current/security/api_key_authentication.html

EDIT - You can have as many user providers as you want and if one fails, then another becomes to play - described here https://symfony.com/doc/current/security/multiple_user_providers.html

Down below is code for ApiKeyAuthenticator which gets the token and calls ApiKeyUserProvider to find/get user for it. In case user is found, than is provided to Symfony security. ApiKeyUserProvider needs UserRepository to user operations - I'm sure you have one, otherwise write it.

Code isn't tested, so little bit of tweaking may be necessary.

So lets get to work:

src/BackendBundle/Security/ApiKeyAuthenticator.php

namespace BackendBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\HttpFoundation\JsonResponse;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
    protected $httpUtils;

    public function __construct(HttpUtils $httpUtils)
    {
        $this->httpUtils = $httpUtils;
    }   

    public function createToken(Request $request, $providerKey)
    {
        //use this only if you want to limit apiKey authentication only for certain url
        //$targetUrl = '/login/check';
        //if (!$this->httpUtils->checkRequestPath($request, $targetUrl)) {
        //    return;
        //}

        // get an apikey from authentication request
        $apiKey = $request->query->get('apikey');
        // or if you want to use an "apikey" header, then do something like this:
        // $apiKey = $request->headers->get('apikey');

        if (!$apiKey) {
            //You can return null just skip the authentication, so Symfony
            // can fallback to another authentication method, if any.
            return null;
            //or you can return BadCredentialsException to fail the authentication
            //throw new BadCredentialsException();
        }

        return new PreAuthenticatedToken(
            'anon.',
            $apiKey,
            $providerKey
        );
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        if (!$username) {
            // CAUTION: this message will be returned to the client
            // (so don't put any un-trusted messages / error strings here)
            throw new CustomUserMessageAuthenticationException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByUsername($username);

        return new PreAuthenticatedToken(
            $user,
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        // this contains information about *why* authentication failed
        // use it, or return your own message
        return new JsonResponse(//$exception, 401);
    }
}

src/BackendBundle/Security/ApiKeyUserProvider.php

namespace BackendBundle\Security;

use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;

use BackendBundle\Entity\User;
use BackendBundle\Entity\UserORMRepository;

class ApiKeyUserProvider implements UserProviderInterface
{
    private $userRepository;

    public function __construct(UserORMRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function getUsernameForApiKey($apiKey)
    {
        //use repository method for getting user from DB by API key
        $user = $this->userRepository->...
        if (!$user) {
            throw new UsernameNotFoundException('User with provided apikey does not exist.');
        }

        return $username;
    }

    public function loadUserByUsername($username)
    {
        //use repository method for getting user from DB by username
        $user = $this->userRepository->...
        if (!$user) {
            throw new UsernameNotFoundException(sprintf('User "%s" does not exist.', $username));
        }
        return $user;
    }

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Expected an instance of ..., but got "%s".', get_class($user)));
        }
        if (!$this->supportsClass(get_class($user))) {
            throw new UnsupportedUserException(sprintf('Expected an instance of %s, but got "%s".', $this->userRepository->getClassName(), get_class($user)));
        }
        //use repository method for getting user from DB by ID
        if (null === $reloadedUser = $this->userRepository->findUserById($user->getId())) {
            throw new UsernameNotFoundException(sprintf('User with ID "%s" could not be reloaded.', $user->getId()));
        }
        return $reloadedUser;
    }

    public function supportsClass($class)
    {
        $userClass = $this->userRepository->getClassName();
        return ($userClass === $class || is_subclass_of($class, $userClass));
    }
} 

Services definition:

services:
    api_key_user_provider:
        class: BackendBundle\Security\ApiKeyUserProvider
    apikey_authenticator:
        class: BackendBundle\Security\ApiKeyAuthenticator
        arguments: ["@security.http_utils"]
        public:    false

And finally security provider config:

providers:
    chain_provider:
        chain:
            providers: [api_key_user_provider, db_username]
    api_key_user_provider:
        id: api_key_user_provider
    db_username:
        entity:
            class: BackendBundle:User
            property: username

I encourage you to study Symfony docs more, there is very good explanation for the authentication process, User entities, User providers, etc.

Question 2: You can achieve different response types for access denied event by defining own Access denied handler:

namespace BackendBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;

class AccessDeniedHandler implements AccessDeniedHandlerInterface
{
    public function handle(Request $request, AccessDeniedException $accessDeniedException)
    {
        $route = $request->get('_route');
        if ($route == 'api')) {
            return new JsonResponse($content, 403);
        } elseif ($route == 'backend')) {
            return new Response($content, 403);
        } else {
            return new Response(null, 403);
        }
    }
}

Upvotes: 2

Related Questions