Jordi Boggiano
Contributed by Jordi Boggiano in #6069

So, you've heard that using the Symfony2 security layer was complex? I tend to both agree and disagree. On the one hand, if your needs are "standard" (form authentication with users stored in a database, HTTP basic authentication, ...), setting up the security is really as easy as configuring some options.

But on the other hand, if you want a custom authentication/authorization/user provider system, things are getting a tad more complex as you need to understand all the concepts and how you need to wire up everything. As of Symfony 2.4, this process has been made easier thanks to the introduction of some easier way to customize the security layer without the need to create a bunch of classes. In this post, I'm going to describe how to code some common features.

Using a Custom User Provider

Before starting to dig into the new customization features, let's simplify things a bit by storing our user credentials in a flat file. Of course, most of the time, the user credentials are stored in a database; and Symfony provides built-in integration for Doctrine and Propel.

To keep the example simple, let's use a simple JSON file:

{
"foo": "37b51d194a7513e45b56f6524f2d51f2", "bob": "098f6bcd4621d373cade4e832627b4f6"

}

The usernames are the keys and the values are the passwords, encoded with the md5 function to keep it simple.

Creating a user provider is as simple as implementing Symfony\Component\Security\Core\User\UserProviderInterface:

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
use Symfony\Component\Security\Core\User\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;

class JsonUserProvider implements UserProviderInterface
{
    protected $users;

    public function __construct()
    {
        $this->users = json_decode(file_get_contents('/path/to/users.json'), true);
    }

    public function loadUserByUsername($username)
    {
        if (isset($this->users[$username])) {
            return new User($username, $this->users[$username], array('ROLE_USER'));
        }

        throw new UsernameNotFoundException(sprintf('Username "%s" does not exist.', $username));
    }

    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());
    }

    public function supportsClass($class)
    {
        return 'Symfony\Component\Security\Core\User\User' === $class;
    }
}

We are using the built-in User class to represent our users. Using this provider in your configuration is straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
    json_user_provider: { class: JsonUserProvider }

security:
    providers:
        json:
            id: json_user_provider

    firewalls:
        secured_area:
            pattern: ^/admin
            provider: json
            # ...

    encoders:
        Symfony\Component\Security\Core\User\User:
            algorithm:        md5
            iterations:       0
            encode_as_base64: false

What's going on here?

  • A json_user_provider service is defined;
  • A json user provider is linked to our json_user_provider service;
  • The json user provider is used for the secured_area firewall;
  • The User password encoder is set to use a simple md5 hash.

Tip

You can learn more about users in the book, and read this cookbook to learn how to create your own user provider.

This user provider is decoupled from the authentication mechanism, and so you can use any of them: a form, an API key, HTTP basic, ...

Using a custom Authenticator

If we want to use a form to let users enter their credentials, we can use the built-in form-login authentication system:

1
2
3
4
5
6
7
8
security:
    firewalls:
        secured_area:
            pattern: ^/admin
            provider: json
            form-login:
                check_path: security_check
                login_path: login

Tip

There are a bunch of ways to configure form-login, and this Security Configuration Reference section of the documentation explain them all.

Now, let's say that we want the users to be able to access our website only between 2 and 4 o-clock in the afternoon (for the UTC timezone). How would you do that? Obviously, there is no built-in configuration setting for such a requirement. So, we need to customize the way the user is authenticated.

And here comes the interesting part. Instead of creating a custom token, factory, listener, and provider, let's use the new Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface interface instead (to keep thing simple, we are extending the user provider here):

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
use Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Symfony\Component\HttpFoundation\Request;

class TimeAuthenticator extends JsonUserProvider implements SimpleFormAuthenticatorInterface
{
    private $encoderFactory;

    public function __construct(EncoderFactoryInterface $encoderFactory)
    {
        $this->encoderFactory = $encoderFactory;
    }

    public function createToken(Request $request, $username, $password, $providerKey)
    {
        return new UsernamePasswordToken($username, $password, $providerKey);
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        try {
            $user = $userProvider->loadUserByUsername($token->getUsername());
        } catch (UsernameNotFoundException $e) {
            throw new AuthenticationException('Invalid username or password');
        }

        $passwordValid = $this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $token->getCredentials(), $user->getSalt());

        if ($passwordValid) {
            $currentHour = date('G');
            if ($currentHour < 14 || $currentHour > 16) {
                throw new AuthenticationException('You can only log in between 2 and 4!', 100);
            }

            return new UsernamePasswordToken($user, 'bar', $providerKey, $user->getRoles());
        }

        throw new AuthenticationException('Invalid username or password');
    }

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

There are a lot of things going on:

  • createToken() creates a Token that will be used to authenticate the user;
  • authenticateToken() checks that the Token is allowed to log in by first getting the User via the user provider and then, by checking the password and the current time (a Token with roles is authenticated);
  • supportsToken() is just a way to allow several authentication mechanism to be used for the same firewall (that way, you can for instance first try to authenticate the user via a certificate or an API key and fallback to a form login);
  • An encoder is needed to check the user password validity; this is a service provided by default:

    1
    $passwordValid = $this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $token->getCredentials(), $user->getSalt());

Now, how do we wire this class into our configuration? Because we have extended Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface, replace form-login by simple-form, and set the authenticator option to the time_authenticator service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
    time_authenticator:
        class:     TimeAuthenticator
        arguments: [@security.encoder_factory]

security:
    providers:
        json:
            id: time_authenticator

    firewalls:
        secured_area:
            pattern: ^/admin
            provider: authenticator
            simple-form:
                provider:      json
                authenticator: time_authenticator
                check_path:    security_check
                login_path:    login

As you can see, no need to create a listener, and no need to create a configuration factory.

Customizing Authentication Failures and Successes

After the authentication mechanism, you have the chance to tweak the default behavior by adding some methods to you authenticator class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class CustomTimeAuthenticator extends TimeAuthenticator implements AuthenticationFailureHandlerInterface, AuthenticationSuccessHandlerInterface
{
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        error_log('You are out!');
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token)
    {
        error_log(sprintf('Yep, you are in "%s"!', $token->getUsername()));
    }
}

Here, we are using the error_log() function to log some information. But you can also bypass the default behavior altogether by returning a Response instance:

1
2
3
4
5
6
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
    if ($exception->getCode()) {
        return new Response('Not the right time to log in, come back later.');
    }
}

Authenticating Users with API Keys

Nowadays, it's quite usual to authenticate the user via an API key (when developing a web service for instance). The API key is provided for every request and is passed as a query string parameter or via a HTTP header.

Let's use the same JSON file for our API keys, but values are now the user API key. Authenticating a user based on the Request information should be done via a pre-authentication mechanism. The new Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface class allows to implement such a scheme really easily:

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
use Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;

class TimeAuthenticator extends JsonUserProvider implements SimplePreAuthenticatorInterface
{
    protected $apikeys;

    public function __construct()
    {
        parent::__construct();

        $this->apikeys = array_flip($this->users);
    }

    public function createToken(Request $request, $providerKey)
    {
        if (!$request->query->has('apikey')) {
            throw new BadCredentialsException('No API key found');
        }

        return new PreAuthenticatedToken('anon.', $request->query->get('apikey'), $providerKey);
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        $currentHour = date('G');
        if ($currentHour < 14 || $currentHour > 16) {
            throw new AuthenticationException('You can only log in between 2 and 4!', 100);
        }

        $apikey = $token->getCredentials();
        if (!isset($this->apikeys[$apikey])) {
            throw new AuthenticationException(sprintf('API Key "%s" does not exist.', $apikey));
        }

        $user = new User($this->apikeys[$apikey], $apikey, array('ROLE_USER'));

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

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

As you can see, the class looks almost the same as the one based on a form, except that we are using a Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken token class.

To access a resource protected by such an authenticator, you need to add an apikey parameter to the query string, like in http://example.com/admin/foo?apikey=37b51d194a7513e45b56f6524f2d51f2.

The configuration is also straightforward (replace simple-form with simple-preauth):

1
2
3
4
5
6
7
security:
    firewalls:
        secured_area:
            pattern: ^/admin
            simple-preauth:
                provider:      json
                authenticator: time_authenticator

Using several Authenticators in a Firewall

If you application is able to return both an HTML and a JSON/XML representation of the resources, it might be a good idea to support both an API-key based authentication mechanism (for programmatic access) and a regular authentication login form (for browsers).

Doing so is as easy as it can get:

1
2
3
4
5
6
7
8
9
10
11
12
security:
    firewalls:
        secured_area:
            pattern: ^/admin
            simple-preauth:
                provider:      json
                authenticator: pre_auth_time_authenticator
            simple-form:
                provider:      json
                authenticator: form_time_authenticator
                check_path:    security_check
                login_path:    login

Conclusion

I hope that this new way to customize the Security features of Symfony will help lower the barrier of entry to new developers. This new feature is still experimental and might change in until 2.4 is released, based on your feedback. So, try to use it and tell us what your think about it.

Published in #Living on the edge