3

I have an OAuth API that requires an username and a password to get the user object (resource owner password credentials flow). I'm trying to get this end result :

  1. User enters username/password
  2. Symfony exchanges username/password for access and refresh tokens, then fetches the User object and populates a token with the fetched object
  3. User is now authenticated on the website

The issue that I'm having is that I cannot seem to figure out how to do it the best way I can see : with an User provider. The UserProviderInterface asks to implement loadUserByUsername(), however I cannot do that, as I need the username AND the password to fetch the user object.

I tried to implement the SimplePreAuthenticatorInterface, but I still run into the same issue: after creating the PreAuthenticated token in createToken(), I need to authenticate it using authenticateToken(), and I still cannot fetch the user through the UserProvider, since I first have to use the username/password to get an access token that'd allow me to fetch the User object. I thought about adding a method to login in my UserProvider that'd login through the API using username/password and store the logged in tokens for any username in an array, and then fetch the tokens by username in that array, but that doesn't feel right.

Am I looking at it from the wrong angle ? Should I not be using PreAuthenticated tokens at all ?

azenet
  • 379
  • 1
  • 6
  • 14

1 Answers1

8

A while ago i needed to implement a way to authenticate users through a webservice. This is what i end up doing based on this doc and the form login implementation of the symfony core.

First create a Token that represents the User authentication data present in the request:

use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;

class WebserviceAuthToken extends AbstractToken
{
    /**
     * The password of the user.
     *
     * @var string
     */
    private $password;

    /**
     * Authenticated Session ID.
     *
     * @var string
     */
    private $authSessionID;

    public function __construct($user, $password, array $roles = array())
    {
        parent::__construct($roles);

        $this->setUser($user);
        $this->password = $password;

        parent::setAuthenticated(count($roles) > 0);

    }

    /**
     * {@inheritDoc}
     */
    public function getCredentials()
    {
        return '';
    }

    /**
     * Returns the Authenticated Session ID.
     *
     * @return string
     */
    public function getAuthSessionID()
    {
        return $this->authSessionID;
    }

    /**
     * Sets the Authenticated Session ID.
     *
     * @param string $authSessionID
     */
    public function setAuthSessionID($authSessionID)
    {
        $this->authSessionID = $authSessionID;
    }

    /**
     * Returns the Password used to attempt login.
     *
     * @return string
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * {@inheritDoc}
     */
    public function serialize()
    {
        return serialize(array(
            $this->authSessionID,
            parent::serialize()
        ));
    }

    /**
     * {@inheritDoc}
     */
    public function unserialize($serialized)
    {
        $data = unserialize($serialized);
            list(
                $this->authSessionID,
                $parent,
            ) = $data;

        parent::unserialize($parent);
    }

}

The AuthSessionID that im storing is a token returned from the webservice that allows me to perform requests as an authenticated user.

Create a Webservice authentication listener which is responsible for fielding requests to the firewall and calling the authentication provider:

use RPanelBundle\Security\Authentication\Token\RPanelAuthToken;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

class WebserviceAuthListener extends AbstractAuthenticationListener
{
    private $csrfTokenManager;

    /**
     * {@inheritdoc}
     */
    public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = array(), LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null, $csrfTokenManager = null)
    {
        if ($csrfTokenManager instanceof CsrfProviderInterface) {
            $csrfTokenManager = new CsrfProviderAdapter($csrfTokenManager);
        } elseif (null !== $csrfTokenManager && !$csrfTokenManager instanceof CsrfTokenManagerInterface) {
            throw new InvalidArgumentException('The CSRF token manager should be an instance of CsrfProviderInterface or CsrfTokenManagerInterface.');
        }

        parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge(array(
            'username_parameter' => '_username',
            'password_parameter' => '_password',
            'csrf_parameter' => '_csrf_token',
            'intention' => 'authenticate',
            'post_only' => true,
        ), $options), $logger, $dispatcher);

        $this->csrfTokenManager = $csrfTokenManager;
    }

    /**
     * {@inheritdoc}
     */
    protected function requiresAuthentication(Request $request)
    {
        if ($this->options['post_only'] && !$request->isMethod('POST')) {
            return false;
        }

        return parent::requiresAuthentication($request);
    }

    /**
     * {@inheritdoc}
     */
    protected function attemptAuthentication(Request $request)
    {
        if (null !== $this->csrfTokenManager) {
            $csrfToken = $request->get($this->options['csrf_parameter'], null, true);

            if (false === $this->csrfTokenManager->isTokenValid(new CsrfToken($this->options['intention'], $csrfToken))) {
                throw new InvalidCsrfTokenException('Invalid CSRF token.');
            }
        }

        if ($this->options['post_only']) {
            $username = trim($request->request->get($this->options['username_parameter'], null, true));
            $password = $request->request->get($this->options['password_parameter'], null, true);
        } else {
            $username = trim($request->get($this->options['username_parameter'], null, true));
            $password = $request->get($this->options['password_parameter'], null, true);
        }

        $request->getSession()->set(Security::LAST_USERNAME, $username);

        return $this->authenticationManager->authenticate(new WebserviceAuthToken($username, $password));
    }

}

Create a Webservice login factory where we wook into the Security Component, and tell which is the User Provider and the available options:

class WebserviceFormLoginFactory extends FormLoginFactory
{
    /**
     * {@inheritDoc}
     */
    public function getKey()
    {
        return 'webservice-form-login';
    }

    /**
     * {@inheritDoc}
     */
    protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId)
    {
        $provider = 'app.security.authentication.provider.'.$id;

        $container
            ->setDefinition($provider, new DefinitionDecorator('app.security.authentication.provider'))
            ->replaceArgument(1, new Reference($userProviderId))
            ->replaceArgument(2, $id);

        return $provider;
    }

    /**
     * {@inheritDoc}
     */
    protected function getListenerId()
    {
        return 'app.security.authentication.listener';
    }

}

Create an Authentication provider that will verify the validaty of the WebserviceAuthToken

class WebserviceAuthProvider implements AuthenticationProviderInterface
{
    /**
     * Service to handle DMApi account related calls.
     *
     * @var AccountRequest
     */
    private $apiAccountRequest;

    /**
     * User provider service.
     *
     * @var UserProviderInterface
     */
    private $userProvider;

    /**
     * Security provider key.
     *
     * @var string
     */
    private $providerKey;

    public function __construct(AccountRequest $apiAccountRequest, UserProviderInterface $userProvider, $providerKey)
    {
        $this->apiAccountRequest = $apiAccountRequest;
        $this->userProvider = $userProvider;
        $this->providerKey = $providerKey;
    }

    /**
     * {@inheritdoc}
     */
    public function authenticate(TokenInterface $token)
    {
        // Check if both username and password exist
        if (!$username = $token->getUsername()) {
            throw new AuthenticationException('Username is required to authenticate.');
        }

        if (!$password = $token->getPassword()) {
            throw new AuthenticationException('Password is required to authenticate.');
        }

        // Authenticate the User against the webservice
        $loginResult = $this->apiAccountRequest->login($username, $password);

        if (!$loginResult) {
            throw new BadCredentialsException();
        }

        try {

            $user = $this->userProvider->loadUserByWebserviceResponse($loginResult);

            // We dont need to store the user password
            $authenticatedToken = new WebserviceAuthToken($user->getUsername(), "", $user->getRoles());
            $authenticatedToken->setUser($user);
            $authenticatedToken->setAuthSessionID($loginResult->getAuthSid());
            $authenticatedToken->setAuthenticated(true);

            return $authenticatedToken;

        } catch (\Exception $e) {
            throw $e;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function supports(TokenInterface $token)
    {
        return $token instanceof WebserviceAuthToken;
    }

}

And finally create a User provider. In my case after i receive the response from the webservice, i check if the user is stored on redis, and if not i create it. After that the user is always loaded from redis.

class WebserviceUserProvider implements UserProviderInterface
{

    /**
     * Wrapper to Access the Redis.
     *
     * @var RedisDao
     */
    private $redisDao;

    public function __construct(RedisDao $redisDao)
    {
        $this->redisDao = $redisDao;
    }

    /**
     * {@inheritdoc}
     */
    public function loadUserByUsername($username)
    {
        // Get the UserId based on the username
        $userId = $this->redisDao->getUserIdByUsername($username);

        if (!$userId) {
            throw new UsernameNotFoundException("Unable to find an UserId identified by Username = $username");
        }

        if (!$user = $this->redisDao->getUser($userId)) {
            throw new UsernameNotFoundException("Unable to find an User identified by ID = $userId");
        }

        if (!$user instanceof User) {
            throw new UnsupportedUserException();
        }

        return $user;
    }

    /**
     * Loads an User based on the webservice response.
     *
     * @param  \AppBundle\Service\Api\Account\LoginResult $loginResult
     * @return User
     */
    public function loadUserByWebserviceResponse(LoginResult $loginResult)
    {
        $userId = $loginResult->getUserId();
        $username = $loginResult->getUsername();

        // Checks if this user already exists, otherwise we need to create it
        if (!$user = $this->redisDao->getUser($userId)) {

            $user = new User($userId, $username);

            if (!$this->redisDao->setUser($user) || !$this->redisDao->mapUsernameToId($username, $userId)) {
                throw new \Exception("Couldnt create a new User for username = $username");
            }

        }

        if (!$user instanceof User) {
            throw new UsernameNotFoundException();
        }

        if (!$this->redisDao->setUser($user)) {
            throw new \Exception("Couldnt Update Data for for username = $username");
        }

        return $this->loadUserByUsername($username);
    }

    /**
     * {@inheritdoc}
     */
    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(
                sprintf('Instances of "%s" are not supported.', get_class($user))
            );
        }

        return $this->loadUserByUsername($user->getUsername());
    }

    /**
     * {@inheritdoc}
     */
    public function supportsClass($class)
    {
        return $class === 'AppBundle\Entities\User';
    }
}

Required services :

app.security.user.provider:
        class: AppBundle\Security\User\WebserviceUserProvider
        arguments: ["@app.dao.redis"]

    app.security.authentication.provider:
        class: AppBundle\Security\Authentication\Provider\WebserviceAuthProvider
        arguments: ["@api_caller", "", ""]

    app.security.authentication.listener:
        class: AppBundle\Security\Firewall\WebserviceAuthListener
        abstract:  true
        parent: security.authentication.listener.abstract

Configured security:

security:
    providers:
        app_user_provider:
            id: app.security.user.provider

    firewalls:
        default:
            pattern: ^/
            anonymous: ~
            provider: app_user_provider
            webservice_form_login: # Configure just like form_login from the Symfony core

If you have any question please let me know.

João Alves
  • 1,931
  • 17
  • 25
  • Thanks for your detailed answer. I do have a question, as I feel this still contains part of my issue: in your UserProvider you do store an User object in your redis datastore, but that seems to me like having an array in my UserProvider that stores credentials temporarily so that it can be fetched in the `loadUserByUsername()` method. Is this the only way this can be done ? – azenet Oct 06 '15 at 10:11
  • 1
    loadUserByUsername needs to return a class that implements UserInterface. You can store your credentials in any place you want since loadUserByUsername follows the Symfony Security requirements. – João Alves Oct 06 '15 at 19:14
  • Where did you put the factory and add it to the stack? In my case (sf3.2) the DependencyInjection folder wasn't there, so I created it. But I don't think the factory is loaded and used. – rolandow Feb 24 '17 at 08:12