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
:
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 ourjson_user_provider
service; - The
json
user provider is used for thesecured_area
firewall; - The
User
password encoder is set to use a simplemd5
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
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 theToken
is allowed to log in by first getting theUser
via the user provider and then, by checking the password and the current time (aToken
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
,
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
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
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.
Nice post! Shouldn't this be a cookbook entry?
Good names for the new classes/methods are probably the most important things that we need to get right. See https://github.com/symfony/symfony/issues/8268 for a discussion about them.
It should be form_login instead of form-login, shouldn't it?
Very interesting post!
Nice post!
I have a question, why
you assigned 'bar' as password, i guess is just a fill in, but why do we have to do this? in which way it impacts the handling of the token later on? or should it be $user->getPassword()?
also aren't you missing from the constructor $this->users?
another refactor
should be
as you already have it defined
I guess it could help to see how the same is done but with 2.2 legacy code. This could help comparing what is going on behind scenes.
The feature seems neat, just wonder how robust can be and if there are instances in which is better to just do it the old way than relying on this at some point.