The past few days I have really be struggeling with the Symfony2 security component. It is the most complex component of Symfony2 if you ask me! On the symfony.com website there is a pretty neat cookbook article about creating a custom authentication provider. Despite the fact that it covers the subject pretty well, it lacks support for form-based authentication use cases. In the current Symfony2 project I’m working on, we’re dealing with a web service that we need to authenticate against. So the cookbook article was nothing more then a good introduction unfortunately.
Using DaoAuthenticationProvider as example
Since we don’t want to reinvent the wheel, a good place to start is by investigating the providers that are in the Symfony2 core. The DaoAuthenticationProvider is a very good example, and used by the default form login. We are going to add a few pieces of code, so we can use the listener and configuration settings. The only thing we want to change are the authentication itself and the user provider. If you take a look at the link above, you will see the only thing we need to change is the checkAuthentication
method. But, a few more steps are needed in order to make things function correctly. Let’s begin! 🙂
We also need a UserProvider!
First things first: we need a custom user provider. The task of the user provider is load the user from a source so the authentication process can continue. Because a user can already be registered at the webservice a traditional database user provider won’t work. We need to create a local record for every user that registers or logs in and doesn’t have an account. So basically the user provider is only responsible for loading and creating a user record. In this example I save the user immediately when there is no record; probably you want to do this after authenticating.
The code for the use provider looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
<?php namespace Acme\DemoBundle\Security\Core\User; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Acme\DemoBundle\Service\Service; use Acme\DemoBundle\Entity\User; use Doctrine\ORM\EntityManager; class WebserviceUserProvider implements UserProviderInterface { private $service; private $em; public function __construct(Service $service, EntityManager $em) { $this->service = $service; $this->em = $em; } public function loadUserByUsername($username) { // Do we have a local record? if ($user = $this->findUserBy(array('email' => $username))) { return $user; } // Try service if ($record = $this->service->getUser($username)) { // Set some fields $user = new User(); $user->setUsername($username); return $user; } throw new UsernameNotFoundException(sprintf('No record found for user %s', $username)); } public function refreshUser(UserInterface $user) { return $this->loadUserByUsername($user->getUsername()); } public function supportsClass($class) { return $class === 'Acme\DemoBundle\Entity\User'; } protected function findUserBy(array $criteria) { $repository = $this->em->getRepository('Acme\DemoBundle\Entity\User'); return $repository->findOneBy($criteria); } } |
We add it to our services configuration in app/config/services.yml
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> <parameters> <parameter key="security.user.provider.acme.service.class">Acme\DemoBundle\Security\Core\User\WebserviceUserProvider</parameter> <parameter key="acme.service.class">Acme\DemoBundle\Service\Service</parameter> </parameters> <services> <service id="acme_demo_webservice" class="%acme.service.class%"> </service> <service id="acme_demo_user_provider" class="%security.user.provider.acme.service.class%"> <argument type="service" id="acme_demo_webservice" /> <argument type="service" id="doctrine.orm.entity_manager" /> </service> </services> </container> |
Creating the AuthenticationProvider
As I said earlier we are going to base our provider on the DaoAuthenticationProvider
. In my bundle I created a new class called ServiceAuthenticationProvider
. Like our example we are extending the abstract UserAuthenticationProvider. Besides the checkAuthentication
method we also must implement the retrieveUser
method. We inject the service through the constructor, so the class looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
<?php namespace Acme\DemoBundle\Security\Core\Authentication\Provider; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authentication\Provider\UserAuthenticationProvider; use Acme\DemoBundle\Service\Service; class EpsAuthenticationProvider extends UserAuthenticationProvider { private $encoderFactory; private $userProvider; private $service; /** * @param Service $service * @param \Symfony\Component\Security\Core\User\UserProviderInterface $userProvider * @param UserCheckerInterface $userChecker * @param $providerKey * @param EncoderFactoryInterface $encoderFactory * @param bool $hideUserNotFoundExceptions */ public function __construct( Service $service, UserProviderInterface $userProvider, UserCheckerInterface $userChecker, $providerKey, EncoderFactoryInterface $encoderFactory, $hideUserNotFoundExceptions = true ) { parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions); $this->encoderFactory = $encoderFactory; $this->userProvider = $userProvider; $this->service = $service; } /** * {@inheritdoc} */ protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token) { $currentUser = $token->getUser(); if ($currentUser instanceof UserInterface) { if ($currentUser->getPassword() !== $user->getPassword()) { throw new BadCredentialsException('The credentials were changed from another session.'); } } else { if (!$presentedPassword = $token->getCredentials()) { throw new BadCredentialsException('The presented password cannot be empty.'); } if (!$this->service->authenticate($token->getUser(), $presentedPassword)) { throw new BadCredentialsException('The presented password is invalid.'); } } } /** * {@inheritdoc} */ protected function retrieveUser($username, UsernamePasswordToken $token) { $user = $token->getUser(); if ($user instanceof UserInterface) { return $user; } try { $user = $this->userProvider->loadUserByUsername($username); if (!$user instanceof UserInterface) { throw new AuthenticationServiceException('The user provider must return a UserInterface object.'); } return $user; } catch (UsernameNotFoundException $notFound) { throw $notFound; } catch (\Exception $repositoryProblem) { throw new AuthenticationServiceException($repositoryProblem->getMessage(), $token, 0, $repositoryProblem); } } } |
Note the call to $this->service->authenticate
where the magic happens. The retrieveUser
method receives a User
instance from our user provider. Although this is not really clear in the code above, it will be after configuration in the service container. We use the configuration from the Symfony core and adjust it to our needs:
1 2 3 4 5 6 7 8 |
<service id="security.authentication_provider.acme_demo_webservice" class="%security.authentication.provider.acme_service.class%" abstract="true" public="false"> <argument type="service" id="acme_demo_webservice" /> <argument /> <!– User Provider –> <argument type="service" id="security.user_checker" /> <argument /> <!– Provider-shared Key –> <argument type="service" id="security.encoder_factory" /> <argument>%security.authentication.hide_user_not_found%</argument> </service> |
Please note the empty arguments. Look a bit strange, huh? These will be magically filled when the container is build by our Factory! This is a bit tricky, and the cookbook explains pretty wel, so I suggest to take a look there. We are extending the FormLoginFactory
because we want to change it bit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<?php namespace Acme\DemoBundle\DependencyInjection\Factory; use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory; class SecurityFactory extends FormLoginFactory { public function getKey() { return 'webservice-login'; } protected function getListenerId() { return 'security.authentication.listener.form'; } protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId) { $provider = 'security.authentication_provider.acme_demo_webservice.'.$id; $container ->setDefinition($provider, new DefinitionDecorator('security.authentication_provider.acme_demo_webservice')) ->replaceArgument(1, new Reference($userProviderId)) ->replaceArgument(3, $id); return $provider; } } |
Add the builder in the Acme\DemoBundle\AcmeDemoBundle.php
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<?php namespace Acme\DemoBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Acme\DemoBundle\DependencyInjection\Factory\SecurityFactory; class AcmeDemoBundle extends Bundle { public function build(ContainerBuilder $container) { $extension = $container->getExtension('security'); $extension->addSecurityListenerFactory(new SecurityFactory()); } } |
Finally, change your security config:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
jms_security_extra: secure_all_services: false expressions: true security: encoders: Symfony\Component\Security\Core\User\User: plaintext role_hierarchy: ROLE_ADMIN: ROLE_USER ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH] providers: acme_provider: id: acme_demo_user_provider firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false login: pattern: ^/demo/secured/login$ security: false secured_area: pattern: ^/demo/secured/ webservice-login: check_path: /demo/secured/login_check login_path: /demo/secured/login provider: acme_provider logout: path: /demo/secured/logout target: /demo/ access_control: |
The webservice-login
key activates our authentication provider. The user provider is defined under providers
as acme_provider
with the corresponding service id.
I used the AcmeDemo bundle from symfony-standard repository, so you could just copy paste most of my code to see everything in action! Only thing you need to provide yourself is a dummy webservice.
Happy coding!