How to Create a custom Authentication Provider

How to Create a custom Authentication Provider

Caution

Creating a custom authentication system is hard, and almost definitely not needed. Instead, see Custom Authentication System with Guard (API Token Example) for a simple way to create an authentication system you will love. Do not keep reading unless you want to learn the lowest level details of authentication.

Symfony provides support for the most common authentication mechanisms. However, your app may need to integrated with some proprietary single-sign-on system or some legacy authentication mechanism. In those cases you could create a custom authentication provider. This article discusses the core classes involved in the authentication process, and how to implement a custom authentication provider. Because authentication and authorization are separate concepts, this extension will be user-provider agnostic, and will function with your application’s user providers, may they be based in memory, a database, or wherever else you choose to store them.

Meet WSSE

The following article demonstrates how to create a custom authentication provider for WSSE authentication. The security protocol for WSSE provides several security benefits:

  1. Username / Password encryption
  2. Safe guarding against replay attacks
  3. No web server configuration required

WSSE is very useful for the securing of web services, may they be SOAP or REST.

There is plenty of great documentation on WSSE, but this article will focus not on the security protocol, but rather the manner in which a custom protocol can be added to your Symfony application. The basis of WSSE is that a request header is checked for encrypted credentials, verified using a timestamp and nonce, and authenticated for the requested user using a password digest.

Note

WSSE also supports application key validation, which is useful for web services, but is outside the scope of this article.

The Token

The role of the token in the Symfony security context is an important one. A token represents the user authentication data present in the request. Once a request is authenticated, the token retains the user’s data, and delivers this data across the security context. First, you’ll create your token class. This will allow the passing of all relevant information to your authentication provider:

  1. // src/Security/Authentication/Token/WsseUserToken.php
  2. namespace App\Security\Authentication\Token;
  3. use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
  4. class WsseUserToken extends AbstractToken
  5. {
  6. public $created;
  7. public $digest;
  8. public $nonce;
  9. public function __construct(array $roles = [])
  10. {
  11. parent::__construct($roles);
  12. // If the user has roles, consider it authenticated
  13. $this->setAuthenticated(count($roles) > 0);
  14. }
  15. public function getCredentials(): string
  16. {
  17. return '';
  18. }
  19. }

Note

The WsseUserToken class extends the Security component’s Symfony\Component\Security\Core\Authentication\Token\AbstractToken class, which provides basic token functionality. Implement the Symfony\Component\Security\Core\Authentication\Token\TokenInterface on any class to use as a token.

The Listener

Next, you need a listener to listen on the firewall. The listener is responsible for fielding requests to the firewall and calling the authentication provider. Listener is a callable, so you have to implement an __invoke() method. A security listener should handle the Symfony\Component\HttpKernel\Event\RequestEvent event, and set an authenticated token in the token storage if successful:

  1. // src/Security/Firewall/WsseListener.php
  2. namespace App\Security\Firewall;
  3. use App\Security\Authentication\Token\WsseUserToken;
  4. use Symfony\Component\HttpFoundation\Response;
  5. use Symfony\Component\HttpKernel\Event\RequestEvent;
  6. use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
  7. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  8. use Symfony\Component\Security\Core\Exception\AuthenticationException;
  9. class WsseListener
  10. {
  11. protected $tokenStorage;
  12. protected $authenticationManager;
  13. public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager)
  14. {
  15. $this->tokenStorage = $tokenStorage;
  16. $this->authenticationManager = $authenticationManager;
  17. }
  18. public function __invoke(RequestEvent $event): void
  19. {
  20. $request = $event->getRequest();
  21. $wsseRegex = '/UsernameToken Username="(?P<username>[^"]+)", PasswordDigest="(?P<digest>[^"]+)", Nonce="(?P<nonce>[a-zA-Z0-9+\/]+={0,2})", Created="(?P<created>[^"]+)"/';
  22. if (!$request->headers->has('x-wsse') || 1 !== preg_match($wsseRegex, $request->headers->get('x-wsse'), $matches)) {
  23. return;
  24. }
  25. $token = new WsseUserToken();
  26. $token->setUser($matches['username']);
  27. $token->digest = $matches['digest'];
  28. $token->nonce = $matches['nonce'];
  29. $token->created = $matches['created'];
  30. try {
  31. $authToken = $this->authenticationManager->authenticate($token);
  32. $this->tokenStorage->setToken($authToken);
  33. return;
  34. } catch (AuthenticationException $failed) {
  35. // ... you might log something here
  36. // To deny the authentication clear the token. This will redirect to the login page.
  37. // Make sure to only clear your token, not those of other authentication listeners.
  38. // $token = $this->tokenStorage->getToken();
  39. // if ($token instanceof WsseUserToken && $this->providerKey === $token->getProviderKey()) {
  40. // $this->tokenStorage->setToken(null);
  41. // }
  42. // return;
  43. }
  44. // By default deny authorization
  45. $response = new Response();
  46. $response->setStatusCode(Response::HTTP_FORBIDDEN);
  47. $event->setResponse($response);
  48. }
  49. }

This listener checks the request for the expected X-WSSE header, matches the value returned for the expected WSSE information, creates a token using that information, and passes the token on to the authentication manager. If the proper information is not provided, or the authentication manager throws an Symfony\Component\Security\Core\Exception\AuthenticationException, a 401 Response is returned.

Note

A class not used above, the Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener class, is a very useful base class which provides commonly needed functionality for security extensions. This includes maintaining the token in the session, providing success / failure handlers, login form URLs, and more. As WSSE does not require maintaining authentication sessions or login forms, it won’t be used for this example.

Note

Returning prematurely from the listener is relevant only if you want to chain authentication providers (for example to allow anonymous users). If you want to forbid access to anonymous users and have a 404 error, you should set the status code of the response before returning.

The Authentication Provider

The authentication provider will do the verification of the WsseUserToken. Namely, the provider will verify the Created header value is valid within five minutes, the Nonce header value is unique within five minutes, and the PasswordDigest header value matches with the user’s password:

  1. // src/Security/Authentication/Provider/WsseProvider.php
  2. namespace App\Security\Authentication\Provider;
  3. use App\Security\Authentication\Token\WsseUserToken;
  4. use Psr\Cache\CacheItemPoolInterface;
  5. use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
  6. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  7. use Symfony\Component\Security\Core\Exception\AuthenticationException;
  8. use Symfony\Component\Security\Core\User\UserProviderInterface;
  9. class WsseProvider implements AuthenticationProviderInterface
  10. {
  11. private $userProvider;
  12. private $cachePool;
  13. public function __construct(UserProviderInterface $userProvider, CacheItemPoolInterface $cachePool)
  14. {
  15. $this->userProvider = $userProvider;
  16. $this->cachePool = $cachePool;
  17. }
  18. public function authenticate(TokenInterface $token): WsseUserToken
  19. {
  20. $user = $this->userProvider->loadUserByUsername($token->getUsername());
  21. if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
  22. $authenticatedToken = new WsseUserToken($user->getRoles());
  23. $authenticatedToken->setUser($user);
  24. return $authenticatedToken;
  25. }
  26. throw new AuthenticationException('The WSSE authentication failed.');
  27. }
  28. /**
  29. * This function is specific to Wsse authentication and is only used to help this example
  30. *
  31. * For more information specific to the logic here, see
  32. * https://github.com/symfony/symfony-docs/pull/3134#issuecomment-27699129
  33. */
  34. protected function validateDigest($digest, $nonce, $created, $secret): bool
  35. {
  36. // Check created time is not in the future
  37. if (strtotime($created) > time()) {
  38. return false;
  39. }
  40. // Expire timestamp after 5 minutes
  41. if (time() - strtotime($created) > 300) {
  42. return false;
  43. }
  44. // Try to fetch the cache item from pool
  45. $cacheItem = $this->cachePool->getItem(md5($nonce));
  46. // Validate that the nonce is *not* in cache
  47. // if it is, this could be a replay attack
  48. if ($cacheItem->isHit()) {
  49. // In a real world application you should throw a custom
  50. // exception extending the AuthenticationException
  51. throw new AuthenticationException('Previously used nonce detected');
  52. }
  53. // Store the item in cache for 5 minutes
  54. $cacheItem->set(null)->expiresAfter(300);
  55. $this->cachePool->save($cacheItem);
  56. // Validate Secret
  57. $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));
  58. return hash_equals($expected, $digest);
  59. }
  60. public function supports(TokenInterface $token): bool
  61. {
  62. return $token instanceof WsseUserToken;
  63. }
  64. }

Note

The Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface requires an authenticate() method on the user token, and a supports() method, which tells the authentication manager whether or not to use this provider for the given token. In the case of multiple providers, the authentication manager will then move to the next provider in the list.

The Factory

You have created a custom token, custom listener, and custom provider. Now you need to tie them all together. How do you make a unique provider available for every firewall? The answer is by using a factory. A factory is where you hook into the Security component, telling it the name of your provider and any configuration options available for it. First, you must create a class which implements Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface:

  1. // src/DependencyInjection/Security/Factory/WsseFactory.php
  2. namespace App\DependencyInjection\Security\Factory;
  3. use App\Security\Authentication\Provider\WsseProvider;
  4. use App\Security\Firewall\WsseListener;
  5. use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
  6. use Symfony\Component\Config\Definition\Builder\NodeDefinition;
  7. use Symfony\Component\DependencyInjection\ChildDefinition;
  8. use Symfony\Component\DependencyInjection\ContainerBuilder;
  9. use Symfony\Component\DependencyInjection\Reference;
  10. class WsseFactory implements SecurityFactoryInterface
  11. {
  12. public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint): array
  13. {
  14. $providerId = 'security.authentication.provider.wsse.'.$id;
  15. $container
  16. ->setDefinition($providerId, new ChildDefinition(WsseProvider::class))
  17. ->setArgument(0, new Reference($userProvider))
  18. ;
  19. $listenerId = 'security.authentication.listener.wsse.'.$id;
  20. $container->setDefinition($listenerId, new ChildDefinition(WsseListener::class));
  21. return [$providerId, $listenerId, $defaultEntryPoint];
  22. }
  23. public function getPosition(): string
  24. {
  25. return 'pre_auth';
  26. }
  27. public function getKey(): string
  28. {
  29. return 'wsse';
  30. }
  31. public function addConfiguration(NodeDefinition $node): void
  32. {
  33. }
  34. }

The Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface requires the following methods:

create()

Method which adds the listener and authentication provider to the DI container for the appropriate security context.

getPosition()

Returns when the provider should be called. This can be one of pre_auth, form, http or remember_me.

getKey()

Method which defines the configuration key used to reference the provider in the firewall configuration.

addConfiguration()

Method which is used to define the configuration options underneath the configuration key in your security configuration. Setting configuration options are explained later in this article.

Note

A class not used in this example, Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory, is a very useful base class which provides commonly needed functionality for security factories. It may be useful when defining an authentication provider of a different type.

Now that you have created a factory class, the wsse key can be used as a firewall in your security configuration.

Note

You may be wondering “why do you need a special factory class to add listeners and providers to the dependency injection container?”. This is a very good question. The reason is you can use your firewall multiple times, to secure multiple parts of your application. Because of this, each time your firewall is used, a new service is created in the DI container. The factory is what creates these new services.

Configuration

It’s time to see your authentication provider in action. You will need to do a few things in order to make this work. The first thing is to add the services above to the DI container. Your factory class above makes reference to service ids that may not exist yet: App\Security\Authentication\Provider\WsseProvider and App\Security\Firewall\WsseListener. It’s time to define those services.

  • YAML

    1. # config/services.yaml
    2. services:
    3. # ...
    4. App\Security\Authentication\Provider\WsseProvider:
    5. arguments:
    6. $cachePool: '@cache.app'
    7. App\Security\Firewall\WsseListener:
    8. arguments: ['@security.token_storage', '@security.authentication.manager']
  • XML

    1. <!-- config/services.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <container xmlns="http://symfony.com/schema/dic/services"
    4. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    5. xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
    6. <services>
    7. <service id="App\Security\Authentication\Provider\WsseProvider">
    8. <argument key="$cachePool" type="service" id="cache.app"></argument>
    9. </service>
    10. <service id="App\Security\Firewall\WsseListener">
    11. <argument type="service" id="security.token_storage"/>
    12. <argument type="service" id="security.authentication.manager"/>
    13. </service>
    14. </services>
    15. </container>
  • PHP

    1. // config/services.php
    2. namespace Symfony\Component\DependencyInjection\Loader\Configurator;
    3. use App\Security\Authentication\Provider\WsseProvider;
    4. use App\Security\Firewall\WsseListener;
    5. use Symfony\Component\DependencyInjection\Reference;
    6. return function(ContainerConfigurator $configurator) {
    7. $services = $configurator->services();
    8. $services->set(WsseProvider::class)
    9. ->arg('$cachePool', ref('cache.app'))
    10. ;
    11. $services->set(WsseListener::class)
    12. ->args([
    13. ref('security.token_storage'),
    14. ref('security.authentication.manager'),
    15. ])
    16. ;
    17. };

Now that your services are defined, tell your security context about your factory in the kernel:

  1. // src/Kernel.php
  2. namespace App;
  3. use App\DependencyInjection\Security\Factory\WsseFactory;
  4. // ...
  5. class Kernel extends BaseKernel
  6. {
  7. public function build(ContainerBuilder $container): void
  8. {
  9. $extension = $container->getExtension('security');
  10. $extension->addSecurityListenerFactory(new WsseFactory());
  11. }
  12. // ...
  13. }

You are finished! You can now define parts of your app as under WSSE protection.

  • YAML

    1. # config/packages/security.yaml
    2. security:
    3. # ...
    4. firewalls:
    5. wsse_secured:
    6. pattern: ^/api/
    7. stateless: true
    8. wsse: true
  • XML

    1. <!-- config/packages/security.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <srv:container xmlns="http://symfony.com/schema/dic/security"
    4. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    5. xmlns:srv="http://symfony.com/schema/dic/services"
    6. xsi:schemaLocation="http://symfony.com/schema/dic/services
    7. https://symfony.com/schema/dic/services/services-1.0.xsd">
    8. <config>
    9. <!-- ... -->
    10. <firewall
    11. name="wsse_secured"
    12. pattern="^/api/"
    13. stateless="true"
    14. wsse="true"
    15. />
    16. </config>
    17. </srv:container>
  • PHP

    1. // config/packages/security.php
    2. $container->loadFromExtension('security', [
    3. // ...
    4. 'firewalls' => [
    5. 'wsse_secured' => [
    6. 'pattern' => '^/api/',
    7. 'stateless' => true,
    8. 'wsse' => true,
    9. ],
    10. ],
    11. ]);

Congratulations! You have written your very own custom security authentication provider!

A little Extra

How about making your WSSE authentication provider a bit more exciting? The possibilities are endless. Why don’t you start by adding some sparkle to that shine?

Configuration

You can add custom options under the wsse key in your security configuration. For instance, the time allowed before expiring the Created header item, by default, is 5 minutes. Make this configurable, so different firewalls can have different timeout lengths.

You will first need to edit WsseFactory and define the new option in the addConfiguration() method:

  1. // src/DependencyInjection/Security/Factory/WsseFactory.php
  2. namespace App\DependencyInjection\Security\Factory;
  3. // ...
  4. class WsseFactory implements SecurityFactoryInterface
  5. {
  6. // ...
  7. public function addConfiguration(NodeDefinition $node): void
  8. {
  9. $node
  10. ->children()
  11. ->scalarNode('lifetime')->defaultValue(300)
  12. ->end();
  13. }
  14. }

Now, in the create() method of the factory, the $config argument will contain a lifetime key, set to 5 minutes (300 seconds) unless otherwise set in the configuration. Pass this argument to your authentication provider in order to put it to use:

  1. // src/DependencyInjection/Security/Factory/WsseFactory.php
  2. namespace App\DependencyInjection\Security\Factory;
  3. use App\Security\Authentication\Provider\WsseProvider;
  4. class WsseFactory implements SecurityFactoryInterface
  5. {
  6. public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint): array
  7. {
  8. $providerId = 'security.authentication.provider.wsse.'.$id;
  9. $container
  10. ->setDefinition($providerId, new ChildDefinition(WsseProvider::class))
  11. ->setArgument(0, new Reference($userProvider))
  12. ->setArgument(2, $config['lifetime']);
  13. // ...
  14. }
  15. // ...
  16. }

Note

The WsseProvider class will also now need to accept a third constructor argument - the lifetime - which it should use instead of the hard-coded 300 seconds. This step is not shown here.

The lifetime of each WSSE request is now configurable, and can be set to any desirable value per firewall.

  • YAML

    1. # config/packages/security.yaml
    2. security:
    3. # ...
    4. firewalls:
    5. wsse_secured:
    6. pattern: ^/api/
    7. stateless: true
    8. wsse: { lifetime: 30 }
  • XML

    1. <!-- config/packages/security.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <srv:container xmlns="http://symfony.com/schema/dic/security"
    4. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    5. xmlns:srv="http://symfony.com/schema/dic/services"
    6. xsi:schemaLocation="http://symfony.com/schema/dic/services
    7. https://symfony.com/schema/dic/services/services-1.0.xsd">
    8. <config>
    9. <!-- ... -->
    10. <firewall name="wsse_secured" pattern="^/api/" stateless="true">
    11. <wsse lifetime="30"/>
    12. </firewall>
    13. </config>
    14. </srv:container>
  • PHP

    1. // config/packages/security.php
    2. $container->loadFromExtension('security', [
    3. // ...
    4. 'firewalls' => [
    5. 'wsse_secured' => [
    6. 'pattern' => '^/api/',
    7. 'stateless' => true,
    8. 'wsse' => [
    9. 'lifetime' => 30,
    10. ],
    11. ],
    12. ],
    13. ]);

The rest is up to you! Any relevant configuration items can be defined in the factory and consumed or passed to the other classes in the container.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.