Symfony Guard component without using the whole framework

Implementing authentication in Symfony can be quite complicated. Even more so, if you attempt to use only the Security component without the whole framework. In this post I’ll show you, how you can use the Symfony Guard component with a form login and a logout link. So here’s what you can expect from implementing the code from this blog entry

  • Login with username and password by form
  • Stay logged in by using a session
  • Logout via Hyperlink „/logout“

My aim is to use as little components from Symfony as possible in this tutorial. Every project is different and I don’t know which components you might want to use.

Why?

One of Symfony’s strengths is the modularity of its components. It’s possible to gradually upgrade older applications with Symfony’s components without having to exchange the whole framework.

I also believe, that using the Security component „from scratch“ helps when trying to understand, how Symfony’s security system works.

Another reason might be, that your application does not use the usual configuration via YAML/XML but some other legacy and/or homespun solution. Also if for some reason you can’t use Symfony’s dependency injection mechanisms, this tutorial might be for you.

If neither of these points is valid for your project and you just want to implement form based login in Symfony, you should probably refrain from using individual components and use the whole framework. In that case the official documentation on Security/Guard is a better read than this blog post.

Before we start

Step 1: The Firewall

The security component is very flexible. It is possible to secure different parts of your application with completely different security mechanisms.

  • A RequestMatcher will be used to specify the part of the application (e. g. every path that starts with /secure/ or everything that is accessed via HTTPS or every request that is not from IP-Range x.y.z.123…)
  • AuthenticationListeners are the security mechanisms mentioned above. Their job is to create a – still unauthenticated – token (implements TokenInterface) with credentials. That’s as simple as „Oh, somebody used the login form and entered username and password. I will put username and password in the token.“

Read A Firewall for HTTP Request for more information.

A FirewallMap is needed to tell the application which parts of the application shall use which AuthenticationListeners. Also we need a TokenStorage to store the Token. The token concept might be a little confusing, so I repeat: Tokens hold login credentials (e. g. username & password). They start out unauthenticated and can be authenticated later by an AuthenticationProvider. The job of on AuthenticationProvider is to have a look at the unauthenticated Token and the credentials it holds and authenticate it – or not: „Hmm, okay, this username exists, yep. But this password does not correspond to the password in the database“

Because there can be several AuthenticationProviders, an AuthenticationManager will find the AuthenticationProvider that shall be used.

My Framework-class (code below) implements HttpKernelInterface – that is of course not strictly necessary. I trust that you can change the code accordingly by yourself, if you do not intend to have a Framework class acting as HttpKernel (Request-to-Response-thingie).

In the constructor I setup everything that is strictly necessary. Several methods allow for extending later. The handle()-method is very important. It finds the route to use from the request data, calls the correct controller and returns a HTTP-Response. However, before all that it checks, whether a firewall is already configured and adds a hardcoded default configuration otherwise. That way, even if I forget the code to configure the firewall or it has a bug, I still have my secure areas. Also it makes writing this tutorial easier. Feel free to do it differently, though.

namespace MyFramework;

use \Symfony\Component\HttpKernel\HttpKernelInterface;
use \Symfony\Component\Security\Http;
use \Symfony\Component\Security\Guard;
use \Symfony\Component\Security\Core\Authentication;
use \Symfony\Component\HttpFoundation;
use \Symfony\Component\EventDispatcher\EventDispatcherInterface;
use \Symfony\Component\Security\Http\FirewallMapInterface;
use \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class Framework implements HttpKernelInterface {
   protected $dispatcher;
   protected $firewallMap;
   protected $tokenStorage;
   protected $firewall;
   public function __construct(EventDispatcherInterface $eventDispatcher, FirewallMapInterface $firewallMap = null, TokenStorageInterface $tokenStorage = null) {
      $this->dispatcher = $eventDispatcher;
      $this->firewallMap = $firewallMap ?? new Http\FirewallMap();
      $this->tokenStorage = $tokenStorage ?? new Authentication\Token\Storage\TokenStorage();
   }

   public function addSecuredArea(HttpFoundation\RequestMatcher $requestMatcher, array $firewallListeners) {
      $this->firewallMap->add($requestMatcher, $firewallListeners);
   }

   public function addDefaultSecuredArea() {
      $requestMatcher = new HttpFoundation\RequestMatcher();
      $firewallListeners = array();

      // ...
      // We will come to this later

      $this->addSecuredArea($requestMatcher, $firewallListeners);
   }

   public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) {
      if(is_null($this->firewall)) {
         $this->addDefaultSecuredArea();
         $this->firewall = new Http\Firewall($this->firewallMap, $this->dispatcher);
        $this->dispatcher->addListener(HttpKernel\KernelEvents::REQUEST, array($this->firewall, 'onKernelRequest'));
      }

      // In my framework I have some Session logic here...

      $this->dispatcher->dispatch(HttpKernel\KernelEvents::REQUEST, new HttpKernel\Event\GetResponseEvent($this, $request, $type));
      
      // handle the HTTP-Request here: Routing, calling the Controller, returning the Response ...
   }
}

Already confused? Don’t worry: The add()-method of the FirewallMap allows to add a secure area to your page. It is identified by a RequestMatcher that matches a regular expression – e. g. „^/securepage“. In the code above however my RequestMatcher just matches all Requests, because I have given no arguments to the RequestMatcher.

It is possible to match only very specific areas, e. g. like this:

new HttpFoundation\RequestMatcher('^/securepage', 'example.org', 'post', '127.0.0.1');

A very important part is having the Firewall listen for an event and calling the onKernelRequest-method() if said event is dispatched. The manual suggests the KernelEvents::REQUEST-Event, so that is what I am using. I suppose you could also manually call the onKernelRequest()-method if, for some reason, you do not use the EventDispatcher.

Make sure, that your session is started, before you dispatch your event though, otherwise you will run into problems. You can use Symfony’s Session-system (included in HttpFoundation) for storing session data, but you don’t have to.

The addSecuredArea()-method is just a wrapper, of course you can do without it, if you want to.

Step 2: Here comes Guard!

About Guard.

If you are interested in the internal workings, I suggest having a look at the source code of Guard which is pretty well documented. If you just want a quick way to get working code, this is what you’ll need:

  • A GuardAuthenticatorHandler
  • One GuardAuthenticator per authentication method – We’ll use one for login by form and one for authenticating session data later on
  • GuardAuthenticationProvider(s)
  • An GuardAuthenticationProviderManager
  • An GuardAuthenticationListener that will be added to the list of FirewallListeners
  • An User
  • An UserProvider
  • An UserChecker

Bold means: Yep, you have to put some work in to create them yourself.

While that seems scary on first glance, it is very easy to actually implement. The code below either goes in the addDefaultSecuredArea()-method mentioned above or you can put it someplace more fitting to your needs:

public function addDefaultSecuredArea() {
   $requestMatcher = new HttpFoundation\RequestMatcher();
   $firewallListeners = array();

   // Here come the Guard parts:
   $guardHandler = new Guard\GuardAuthenticatorHandler($this->tokenStorage, $this->dispatcher);

   // The following lines are very important and will be explained later:
   $guardAuthenticators = array(new FormLoginAuthenticator(), new SessionAuthenticator());
   // The $providerKey "guard" should not matter much as long as you only use one Firewall
   $providers = array(new Guard\Provider\GuardAuthenticationProvider($guardAuthenticators, new UserProvider(), 'guard', new UserChecker()));

   // "guard" is the $providerKey again, it should match the value in the line above
   $firewallListeners[] = new Guard\Firewall\GuardAuthenticationListener($guardHandler, new Authentication\AuthenticationProviderManager($providers), 'guard', $guardAuthenticators);

   $this->addSecuredArea($requestMatcher, $firewallListeners);
   // if you don't use my addSecuredArea()-wrapper, use this code instead:
   // $this->firewallMap->add($requestMatcher, $firewallListeners);
}

Step 3: User, UserProvider, UserChecker

Good news: Your framework or application might already have those, probably even with the correct code! Sometimes just a little bit of refactoring or wrapping is required. You just need to implement a few interfaces and you are good to go.

  1. Your User-class must implement UserInterface – you can have a look at the User-class of the Security component (Security\Core\User\User)
  2. Your UserProvider must implement UserProviderInterface. The UserProvider loads an User by username, it will probably interact with your database.
  3. Your UserChecker must implement UserCheckerInterface. Its function is to determine, if an account is disabled, expired, etc. These checks are optional, but having an UserChecker is not. You could also simply use the existing Security\Core\User\UserChecker

My User-class looks somewhat like this:

namespace MyFramework;

use \Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface {
   protected $password;
   protected $username;
   protected $salt;
   public function eraseCredentials() {
      $this->password = '';
   }

   public function getRoles() {
       return array('ROLE_USER');
   }

   public function getSalt() {
       return $this->salt;
   }

   public function getUsername() {
       return $this->username;
   }

   public function getPassword() {
      return $this->password;
   }
}

In my case, I use bcrypt that autogenerates the salt and my $this->salt is always empty. If you don’t know about salting and hashing passwords yet, make sure to read about these concepts before coding anything security-related!

Roles are not part of this blog entry, they are important for authorization later on.

Here is the UserProvider, the actual implementation of loadUserByUsername() depends on where your User’s are stored (e. g. database) and which ORM you use, so this will not work out-of-the-box:

namespace MyFramework;

use \Symfony\Component\Security\Core\User\UserInterface;
use \Symfony\Component\Security\Core\User\UserProviderInterface;
use \Symfony\Component\Security\Core\Exception as SecurityException;

class UserProvider implements UserProviderInterface {
   public function loadUserByUsername($username) {
        try {
            // Adapt to your own needs:
            $user = $this->readUserFromDatabase($username);
            if($user instanceof User) return $user;
        } catch(DatabaseException $e) {
            throw new SecurityException\AuthenticationServiceException($e->getMessage());
        }
        throw new SecurityException\UsernameNotFoundException();
    }

    public function refreshUser(UserInterface $user) {
        if(!$user instanceof User) {
            throw new SecurityException\UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }
        
        return $this->loadUserByUsername($user->getUsername());
    }

    public function supportsClass($class) {
        return $class === 'MyFramework\User';
    }
}

Make sure that you use the correct Exceptions for the different kinds of possible errors that can occur. loadUserByUsername() loads an user e. g. from the database, refreshUser() refreshs user data of a given user, supportsClass() returns a boolean, whether the given Class can be provided by this UserProvider.

My User Checker currently does nothing, you can of course have it check for users that have been banned from your site, that have to renew their credentials, etc.:

namespace MyFramework;

use \Symfony\Component\Security\Core\User\UserInterface;
use \Symfony\Component\Security\Core\User\UserCheckerInterface;

class UserChecker implements UserCheckerInterface {
    public function checkPostAuth(UserInterface $user) {
        
    }

    public function checkPreAuth(UserInterface $user) {
        
    }
}

Step 4: The GuardAuthenticator(s)

If you have any problems with filling out the Authenticator methods in this step, see the „official tutorial“.

In my case, I need two GuardAuthenticators

  1. Authenticate username&password that was entered in a form – we will extend Guard’s AbstractFormLoginAuthenticator in this authenticator
  2. Authenticate username&password from session data (already logged in users should stay logged in as long the session is active) – we will extend Guard’s AbstractGuardAuthenticator here

Both authenticators must implement the GuardAuthenticatorInterface.

Each GuardAuthenticator works like this:

  • getCredentials() returns an array of credentials (username&password) or null
  • those are passed to getUser(). getUser() returns the User corresponding to the given username. For that, the UserProvider will be asked to provide an User (implementing UserInterface). getUser() returns this User or null or throws an Exception
  • the User is passed to checkCredentials(). checkCredentials() checks credentials. That means, it is checked, if the given password is correct for the given User. If NOT or anything goes wrong, an Exception is thrown
  • Suppose the password is correct, then a redirect happens – either to the page you wanted to access in the first place, or to a default URL

I assumed that both authenticators shared a lot of code, so I wrote an AuthenticatorTrait that is used by both classes (first line after the class definition). Turns out that the only shared method is getUser(), though.

Further reading if you don’t intend to use username&password, but e. g. username and/or email

Let’s start with the FormLoginAuthenticator:

<?php
namespace MyFramework;

use \Symfony\Component\Security\Guard;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\Security\Core\User\UserInterface;
use \Symfony\Component\Security\Core\Exception;

class FormLoginAuthenticator extends Guard\Authenticator\AbstractFormLoginAuthenticator implements Guard\GuardAuthenticatorInterface {
    use AuthenticatorTrait;
    
    public function getCredentials(Request $request) {    
        if($request->request->has('username') && $request->request->has('password')) {
            return array('username' => $request->request->get('username'), 'password' => $request->request->get('password'));
        }
        
        return null;
    }

    public function checkCredentials($credentials, UserInterface $user) {
        if($user instanceof User && password_verify($credentials['password'], $user->getPassword())) {

            // I also have some session logic here
            return;
        }

        // User could not be logged in
        throw new SecurityException\BadCredentialsException();
    }

    protected function getDefaultSuccessRedirectUrl() {
        return '/';
    }

    protected function getLoginUrl() {
        return '/';
    }

}

The getCredentials()-method takes submitted form data, to be exact only the fields username and password and returns them in form of an array.

The getDefaultSuccessRedirectUrl() returns the url the user is redirected to after successful login.

The getLoginUrl() returns the url of the login form, in my case it is on the homepage, usually it is something like ‚/login‘

The checkCredentials()-method uses the password_verify()-method to check, whether the password is correct. In my framework I also put the code to save the valid credentials to the session in checkCredentials(), that should probably either go into onAuthenticationSuccess() or should be done by an Event that is dispatched. But…well…it works for now.

When writing the SessionAuthenticator I was a bit conflicted. On the one hand, it should not extend the AbstractFormLoginAuthenticator, because, well, a session is not a form, on the other hand I basically copied and pasted the whole code from Guard’s AbstractFormLoginAuthenticator, because it is easily applicable outside of a form’s context. I decided to go this way, because sometime in the future AbstractFormLoginAuthenticator might have more functionality directly relating to forms.

So this is basically copy&paste from AbstractFormLoginAuthenticator + my AuthenticatorTrait, and, of course the getCredentials()-method:

<?php
namespace MyFramework;

use \Symfony\Component\Security\Guard;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use \Symfony\Component\HttpFoundation\RedirectResponse;
use \Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use \Symfony\Component\Security\Core\Exception\AuthenticationException;
use \Symfony\Component\Security\Core\Security;
use \Symfony\Component\Security\Core\User\UserInterface;

class SessionAuthenticator extends AbstractGuardAuthenticator implements Guard\GuardAuthenticatorInterface {
    use AuthenticatorTrait;
    
    public function getCredentials(Request $request) {       
        $session = $request->getSession();
        
        if ($session->has('username') && $session->has('password')) {
            return array('username' => $session->get('username'), 'password' => $session->get('password'));
        }
        
        return null;
    }

    public function checkCredentials($credentials, UserInterface $user) {
        if($user instanceof User && $credentials['password']===$user->getPassword()) {
            return;
        }
                
        // User could not be restored from session due to a wrong password
        throw new \Symfony\Component\Security\Core\Exception\BadCredentialsException();
    } 

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);

        return new RedirectResponse('/');
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        // if the user hit a secure page and start() was called, this was
        // the URL they were on, and probably where you want to redirect to
        $targetPath = $request->getSession()->get('_security.'.$providerKey.'.target_path');

        if (!$targetPath) {
            $targetPath = '/';
        }

        return new RedirectResponse($targetPath);
    }

    public function supportsRememberMe()
    {
        return false;
    }

    public function start(Request $request, AuthenticationException $authException = null)
    {
        return new RedirectResponse('/');
    }


}

Please note that while this authenticator uses the Session-class from Symfony’s HttpFoundation, it is just as easy to get the Session data from somewhere else here and use your own solution.

getCredentials() retrieves the credentials from session data, the password is of course hashed within the session and not in plain text. onAuthenticationFailure() returns a RedirectResponse to / on failure, onAuthenticationSuccess() tries to redirect to whichever page the user wanted to access. I have not read yet about the supportsRememberMe()-feature that should tell whether a remember me-checkbox-functionality can be used. Because the session authenticator is not a form I return false here, I don’t know yet, if that’s correct.

Finally, here is the AuthenticatorTrait, the getUser()-method should be pretty self-explanatory:

<?php
namespace MyFramework;

use \Symfony\Component\Security\Core;

trait Authenticator {
   public function getUser($credentials, Core\User\UserProviderInterface $userProvider) {
       $user = $userProvider->loadUserByUsername($credentials['username']);
       if($user instanceof User) {
           return $user;
       }
        
       return null;
   }
}

Make sure, that you throw the correct AuthenticationExceptions, e. g. BadCredentialsException only for wrong credentials (wrong password), NOT for a system failure, that’s what AuthenticationServiceException is for. There are lots of great exceptions to choose from, like UsernameNotFoundException or CookieTheftException. 😉

Step 5: Logout

Everything above should already work, so if you are one of those „Logout? Nobody should ever log out of my system“-types, feel free to stop reading.

It is possible to use Guard in conjunction with the „traditional“ way of writing security code. The Security-component includes a LogoutListener, which is a FirewallListener that   listens for people trying to get out. Of course there are easier ways of just clearing your session data than this, but it seems to be the correct way of doing it.

Be advised that the LogoutListener depends on the Routing component. If you have your own Routing system, don’t try to use the following code.

First I added an use-statement for the Routing-component, then three properties ($routeCollection, $urlGenerator, $matcher) and I extended the constructor of my Framework class:

namespace MyFramework;

use \Symfony\Component\HttpKernel\HttpKernelInterface;
use \Symfony\Component\Security\Http;
use \Symfony\Component\Security\Guard;
use \Symfony\Component\Security\Core\Authentication;
use \Symfony\Component\HttpFoundation;
use \Symfony\Component\EventDispatcher\EventDispatcherInterface;
use \Symfony\Component\Security\Http\FirewallMapInterface;
use \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use \Symfony\Component\Routing;

class Framework implements HttpKernelInterface {
   protected $dispatcher;

   protected $firewallMap;
   protected $tokenStorage;
   protected $firewall;

   protected $matcher;
   protected $routeCollection;
   protected $urlGenerator;
   
  public function __construct(Routing\RouteCollection $routes, EventDispatcherInterface $eventDispatcher, FirewallMapInterface $firewallMap = null, TokenStorageInterface $tokenStorage = null) {
      $this->dispatcher = $eventDispatcher;
      $this->firewallMap = $firewallMap ?? new Http\FirewallMap();
      $this->tokenStorage = $tokenStorage ?? new Authentication\Token\Storage\TokenStorage();

      $requestContext = new Routing\RequestContext();        
      $this->routeCollection = $routes;
      $this->matcher = new Routing\Matcher\UrlMatcher($routes, $requestContext);
      $this->urlGenerator = new Routing\Generator\UrlGenerator($this->routeCollection, $requestContext);
   }

The RouteCollection passed to the constructor contains all routes of my application. Make sure, that you have a Route for logout specified (e. g. /logout) which could either point to the start page controller or to a dedicated controller telling your user „You are now successfully logged out“. You can find extensive information about Routing in the official documentation.

Next, the LogoutListener has to be created and added to the firewallListeners, this is the new addDefaultSecuredArea()-method:

public function addDefaultSecuredArea() {
   $requestMatcher = new HttpFoundation\RequestMatcher();
   $firewallListeners = array();
   $guardHandler = new Guard\GuardAuthenticatorHandler($this->tokenStorage, $this->dispatcher);
   $guardAuthenticators = array(new FormLoginAuthenticator(), new SessionAuthenticator());
   $providers = array(new Guard\Provider\GuardAuthenticationProvider($guardAuthenticators, new UserProvider(), 'guard', new UserChecker()));

   // The following two lines are new and necessary for logout
   $utils = new Http\HttpUtils($this->urlGenerator, $this->matcher);
   $firewallListeners[] = new Http\Firewall\LogoutListener($this->tokenStorage, $utils, new LogoutSuccessHandler());

   $firewallListeners[] = new Guard\Firewall\GuardAuthenticationListener($guardHandler, new Authentication\AuthenticationProviderManager($providers), 'guard', $guardAuthenticators);
   $this->addSecuredArea($requestMatcher, $firewallListeners);
}

I used the default options of the LogoutListener, so ‚/logout‘ will be used for logging out.

Next a LogoutSuccessHandler is necessary. It must implement LogoutSuccessHandlerInterface.

Here is my LogoutSuccessHandler:

<?php
namespace MyFramework;

use \Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
use \Symfony\Component\HttpFoundation\Request;

class LogoutSuccessHandler implements LogoutSuccessHandlerInterface {
   public function onLogoutSuccess(Request $request) {
      $request->getSession()->close();
      return new RedirectResponse('/');
   }
}

As you can see, it only removes the credentials from the session data.

Comments?

Most of the code above was adapted a little to fit better into a tutorial, so it has all been simplified, the namespaces changed – I’d be glad if you tell me if you spot any errors. 🙂

I am also willing to answer your questions in the comments.

3 Gedanken zu „Symfony Guard component without using the whole framework

  1. Where is $exceptionListener in $this->firewallMap->add($requestMatcher, $firewallListeners)? I`m tried compile this code.
    After success authentication the method $targetPath = $request->getSession()->get(‚_security.‘.$providerKey.‘.target_path‘); was failed because session variable not found.
    $exceptionListener contains setTargetPath, but the first not used everywhere.

      1. Thanks. I`m reviewed this code one more time. This error have root from

        abstract class AbstractFormLoginAuthenticator extends AbstractGuardAuthenticator
        {

        public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) {
        ….
        $targetPath = $this->getTargetPath($request->getSession(), $providerKey);
        if (!$targetPath) {
        $targetPath = $this->getDefaultSuccessRedirectUrl();
        }

        }

        }

        Firstime session is empty and we haveCatchable fatal error: Argument 1 passed to Symfony\\Component\\Security\\Guard\\Authenticator\\AbstractFormLoginAuthenticator::getTargetPath() must be an instance of Symfony\\Component\\HttpFoundation\\Session\\SessionInterface, null given

        I resolved this problem making: https://github.com/symfony/symfony/issues/18958

        Hmm, How you can avoid this session case? Maybe it`s due with your comment “ // I also have some session logic here
        “ in FormLoginAuthenticator code.
        Anyway thank you for this manual. He helped me very much. If you will add exceptionListener in addition soon- it were very good.

Kommentar verfassen