Symfony2: Security enhancements part II

Posted on by Matthias Noback

There's a much more detailed chapter about this subject in my book A Year With Symfony.

Part II of this series is all about validating the user's session. You can find Part I here, if you missed it.

Collect Failed Authentication Attempts

Now and then a user will forget his password and try a few times before going to the "reset password" page. However, when a "user" keeps trying to authenticate with bad credentials, you may be subject to a brute-force attack. Therefore, you should collect failed authentication attempts. Your strategy may then be to block the account until further notice, while providing the user with a way to re-activate his account. When authentication fails, an event is fired, which you may intercept by registering an event listener or subscriber:

use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;

class AuthenticationFailureListener implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return array(
            AuthenticationEvents::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
        );
    }

    public function onAuthenticationFailure(AuthenticationFailureEvent $event)
    {
        $token = $event->getAuthenticationToken();

        $username = $token->getUsername();

        // ...
    }
}

The corresponding service definition:

<service id="matthias_security.authentication_failure_event_listener"
        class="Matthias\ApplicationBundle\EventListener\AuthenticationFailureListener">
   <tag name="kernel.event_subscriber" />
</service>

You may also want to listen to the AuthenticationEvents::AUTHENTICATION_SUCCESS event to reset the failed attempts counter.

Block Users

When accounts are somehow suspicious (for instance you encountered an unusual number of login attempts) you may want to block the account and prevent the user from authenticating successfully. This is the purpose of the UserChecker from the Security Component. Normally it does some checks on the user object, but only if it implements AdvancedUserInterface. You may extend it yourself, and do some "pre-checks" (before verifying the password) or "post-checks" (after verifying the password - this means the password is found to be valid).

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

class MyUserChecker extends UserChecker
{
    public function checkPreAuth(UserInterface $user)
    {
        // check anything

        parent::checkPreAuth($user);
    }

    public function checkPostAuth(UserInterface $user)
    {
        // check anything

        parent::checkPostAuth($user);
    }
}

When you want to prevent the given user from logging in: throw an exception which is an instance of Symfony\Component\Security\Core\Exception\AccountStatusException.

You should probably replace the existing security.user_checker service or set the parameter security.user_checker.class on the service container (at compile time).

Check Your PHP Settings

You should read everything there is about PHP sessions and (session) cookies. I thought I knew everything I needed to know, but there were some important distinctions I was not aware of, for instance between the expiry date of session cookies, the lifetime of a session and the period in which sessions are stale, but not yet garbage collected. Please check out the Runtime Configuration for Sessions.

Some settings can be changed by modifying config.yml (see the Framework configuration reference), and some may require you to modify your php.ini file.

Invalidate the Session Based on its Age

When the session cookie's lifetime is "0", which means it lives until the browser session ends, it could be wise to kill sessions after a number of inactive minutes anyway. This can be accomplished by inspecting the so-called MetadataBag of the Session.

Listen to the kernel.request event and check if the session is still valid. The priority of the listener should be lower than 128.

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

class SessionListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        if ($event->getRequestType() !== HttpKernelInterface::MASTER_REQUEST) {
            return;
        }

        $session = $event->getRequest()->getSession();
        $metadataBag = $session->getMetadataBag();

        $lastUsed = $metadataBag->getLastUsed();
        if ($lastUsed === null) {
            // the session was created just now
            return;
        }

        // "last used" is a Unix timestamp

        // if a session is being revived after too many seconds:
        $session->invalidate();
    }
}

And the corresponding service definition:

<service id="matthias_security.verify_session_listener"
         class="Matthias\ApplicationBundle\EventListener\SessionListener">
    <tag name="kernel.event_listener"
         event="kernel.request"
         priority="100"
         method="onKernelRequest" />
</service>
PHP Security Symfony2 authentication sessions
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
mauricio

Hi Matthias.

http://stackoverflow.com/qu...

It seems in symfony 2.3:

$session = $event->getRequest()->getSession();
$metadataBag = $session->getMetadataBag();
$lastUsed = $metadataBag->getLastUsed();

Needs:

$session = $event->getRequest()->getSession();
$session->start();

Because otherwise, $lastUsed is always returning NULL and $session->getMetadataBag()->getCreated() is returning 0.

I am using the FOSUserBundle.

Please confirm

Matthias Noback

I wouldn't know actually, I didn't test this code with Symfony 2.3. Please let me know if you can confirm this. Thinking about this, it doesn't really make sense to invalidate a session that wasn't there in the first place. Maybe this code is executed to soon (i.e. the priority of your event listener is too high).

mauricio

I can confirm is the only way It worked. Yea it does not make sense to me either.

This is how I am defining the listener:


pm4_core.invalidate_session_after_inactivity:
class: Pm4\CoreBundle\EventListener\InvalidateSessionAfterInactivityListener
tags:
- { name: kernel.event_listener, event:"kernel.request", priority:"100", method:"onKernelRequest" }

It has 100 as the priority, but for some reason it is too soon and the session has not been started yet. I am using this with fosUserBundle, so maybe its related to that.

I also tested:


$session = $event->getRequest()->getSession();
var_dump($session->isStarted());

and yea, isStarted is always returning FALSE, at Listener Context.

Jhonny

About the "Collect Failed Authentication Attempts" what would be the best way to go from the // ...? part? I was thinking about saving the attempts in a session but I could not find anything useful for this when I tried to dump the event.

Matthias Noback

Hi Johnny,
You should store/log the username in any way you like. Then you can manually block the account with the given username if there have been too many login attempts for that username). Of course, send a mail about this to the user. You should also re-enable the account after some interval.

Sebastiaan Stok

By default the 'container' scope is used which always returns the same instance.
The 'request' scope is dynamic as the 'request' service changes whit each request (including sub-requests), if you inject a service reference with a different scope then the scope that is used by the main-service definition this leads to scope widening as the injected service changes with each request.
http://symfony.com/doc/2.1/...

With the JSMDiExtraBundle you can just add scope="request".
@Service("some.service.id", parent="another.service.id", scope="request")

Sebastiaan Stok

You don't need to inject the whole container, injecting the request service should be enough, don't forget to set scope="request".

As the service is an event listener this should not give an any issues with scope widening.

bandroidx

Thanks Sebastiaan, I will try to do as you suggested. I am using @DiExtraBundle's annotations to create the service so I will just need to figure out how to do it via annotations which I am sure is possible although I don't recall the ability to set the scope in the docs. I am unfamiliar with the issue of scope widening though, the Symfony2 docs are quite thin in this area. Could you elaborate a little more on what you need by Scope widening? thanks!

bandroidx

Just a follow up, i ended up getting the ip by injecting the service container into the listener and grabbing the request from it. This seemed to be the best way I could come up with and seems to work nice.

Now I hope to expand my listener and use it to prevent the user from typing an infinite number of http basic login attempts as symfony allows in its stock state.

Matthias Noback

That's the right solution indeed. I didn't find a problem with $event->getAuthenticationToken(). At least for Symfony 2.2 I get the token directly this way. I have not checked if this has changed between versions.

bandroidx

You are correct, this was a strange issue with my IDE (phpstorm) from what I can tell. It was telling me that the getUsername() method was unavailable, and showed getToken(0 was. When I did getToken() I was able to use the getUsername() method but when I actually implemented it, it errored. I changed it back to how you had it, and then it worked and the IDE didn't complain. So it must have been a bug with PHPStorm, sorry for the confusion. Keep up the great Symfony2 Blog!

bandroidx

also there appears to be a typo in the listener. it has $token = $event>getAuthenticationToken(); $username = $token->getUsername(); -- but it appears you need to do $token = $event>getAuthenticationToken()->getToken(); to be able to get the getUsername() method.

Matthias Noback

The authentication failure handler (and also the success handler) can be used to generate a Response object to be used as the response for the user. These handlers are indeed used by the AbstractAuthenticationListener and therefore by the UsernamePasswordFormAuthenticationListener, but not by BasicAuthenticationListener as you mentioned. The authentication events defined in AuthenticationEvents are the most general events, which are dispatched whatever type of authentication your application uses.

The IP of the user can be retrieved by calling the method Request::getClientIp() on the current request object.

bandroidx

Thanks for your response!! The issue is that I cant find a way to get the current request object from the AuthenticationFailureListener. It doesnt seem possible to get it from $event. Am I missing something?

bandroidx

Hi, how would i get the request so i can get the ip of the user making the bad login request? Also, I was told this could more easily be done by implementing AuthenticationFailureHandlerInterface and setting failure_handler for the form_login. Can you she some light on why you went this way? thanks!

bandroidx

Ok it seems that one good reason of doing it this way is that http_basic isnt caught by failure_handler. I assume this catches http_basic attempts?

That still brings me back to the question of how to get the ip of the request though. thanks

Tobias Sjösten

Wow, I learned lots from reading this! Thanks for sharing, Matthias.

Matthias Noback

Thank you, Tobias.

Greg

The service matthias_security.authentication_failure_event_listener does not need a dependency on the event dispatcher. You may update the service definition.

Matthias Noback

You are right of course (copy-paste error) - changed it!