Prevent simultaneous user sessions in Symfony2
Asked Answered
E

1

9

The goal

We are providing a client with a solution for a multiple-choice practice system where students pay for a monthly membership in order to test their knowledge and prepare for medical-related examinations. A major issue with providing this solution in Symfony2 is that students can buy one subscription, share their credentials with classmates and colleagues, and split the cost of the subscription over multiple concurrent logins.

In order to minimize this problem, we wish to prevent more than one simultaneous session from being maintained in our Symfony2 project.

Research

Massive amounts of Google-fu led me to this sparse Google group thread where OP was briefly told to use PdoSessionHandler to store the sessions in the database.

Here's another SO question where someone else worked around the same thing, but no explanation on how to do it.

Progress so far

I've implemented this handler for the project and currently have a security.interactive_login listener that stores the resulting session ID with the User in the database. The progress is here

public function __construct(SecurityContext $securityContext, Doctrine $doctrine, Container $container)
{
    $this->securityContext = $securityContext;
    $this->doc = $doctrine;
    $this->em              = $doctrine->getManager();
    $this->container        = $container;
}

/**
 * Do the magic.
 * 
 * @param InteractiveLoginEvent $event
 */
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
{
    if ($this->securityContext->isGranted('IS_AUTHENTICATED_FULLY')) {
        // user has just logged in
    }

    if ($this->securityContext->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
        // user has logged in using remember_me cookie
    }

    // First get that user object so we can work with it
    $user = $event->getAuthenticationToken()->getUser();

    // Now check to see if they're a subscriber
    if ($this->securityContext->isGranted('ROLE_SUBSCRIBED')) {
        // Check their expiry date versus now
        if ($user->getExpiry() < new \DateTime('now')) { // If the expiry date is past now, we need to remove their role
            $user->removeRole('ROLE_SUBSCRIBED');
            $this->em->persist($user);
            $this->em->flush();
            // Now that we've removed their role, we have to make a new token and load it into the session
            $token = new \Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken(
                $user,
                null,
                'main',
                $user->getRoles()
            );
            $this->securityContext->setToken($token);
        }
    }

    // Get the current session and associate the user with it
    $sessionId = $this->container->get('session')->getId();
    $user->setSessionId($sessionId);
    $this->em->persist($user);
    $s = $this->doc->getRepository('imcqBundle:Session')->find($sessionId);
    if ($s) { // $s = false, so this part doesn't execute
        $s->setUserId($user->getId());
        $this->em->persist($s);
    }
    $this->em->flush();

    // We now have to log out all other users that are sharing the same username outside of the current session token
    // ... This is code where I would detach all other `imcqBundle:Session` entities with a userId = currently logged in user
}

The problem

The session isn't stored into the database from the PdoSessionHandler until after the security.interactive_login listener is finished, therefore the User ID never ends up getting stored with the session table. How can I make this work? Where can I have the User ID store in the session table?

Alternatively, is there a better way of going about this? This is turning out to be extremely frustrating with Symfony as I don't think it was ever designed to have exclusive single user sessions for each user.

Electrotonus answered 18/8, 2014 at 17:20 Comment(2)
So, you have the following structures in your database: students can buy one subscription, share their credentials with classmates and colleagues, and split the cost of the subscription over multiple concurrent logins. Please show the database tables that implement this structure. what queries are you using to maintain them? The issue with using sessions is that 'they are temporary' and not recorded anywhere permanent. Especially with a group of 'users' who may not be active all at once.Pernas
@RyanVincent The User table is created from Sonata User Bundle (which extends FOSUserBundle.) All of this is default behaviour - no need for me to show you entities and functionality that is within FOSUserBundle and Sonata.Electrotonus
E
14

I've solved my own problem, but will leave the question open for dialogue (if any) before I'm able to accept my own answer.

I created a kernel.request listener that would check the user's current session ID with the latest session ID associated with the user upon each login.

Here's the code:

<?php

namespace Acme\Bundle\Listener;

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernel;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\Routing\Router;

/**
 * Custom session listener.
 */
class SessionListener
{

    private $securityContext;

    private $container;

    private $router;

    public function __construct(SecurityContext $securityContext, Container $container, Router $router)
    {
        $this->securityContext = $securityContext;
        $this->container = $container;
        $this->router = $router;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        if (!$event->isMasterRequest()) {
            return;
        }

        if ($token = $this->securityContext->getToken()) { // Check for a token - or else isGranted() will fail on the assets
            if ($this->securityContext->isGranted('IS_AUTHENTICATED_FULLY') || $this->securityContext->isGranted('IS_AUTHENTICATED_REMEMBERED')) { // Check if there is an authenticated user
                // Compare the stored session ID to the current session ID with the user 
                if ($token->getUser() && $token->getUser()->getSessionId() !== $this->container->get('session')->getId()) {
                    // Tell the user that someone else has logged on with a different device
                    $this->container->get('session')->getFlashBag()->set(
                        'error',
                        'Another device has logged on with your username and password. To log back in again, please enter your credentials below. Please note that the other device will be logged out.'
                    );
                    // Kick this user out, because a new user has logged in
                    $this->securityContext->setToken(null);
                    // Redirect the user back to the login page, or else they'll still be trying to access the dashboard (which they no longer have access to)
                    $response = new RedirectResponse($this->router->generate('sonata_user_security_login'));
                    $event->setResponse($response);
                    return $event;
                }
            }
        }
    }
}

and the services.yml entry:

services:
    acme.session.listener:
        class: Acme\Bundle\Listener\SessionListener
        arguments: ['@security.context', '@service_container', '@router']
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

It's interesting to note that I spent an embarrassing amount of time wondering why my listener was making my application break when I realized that I had previously named imcq.session.listener as session_listener. Turns out Symfony (or some other bundle) was already using that name, and therefore I was overriding its behaviour.

Be careful! This will break implicit login functionality on FOSUserBundle 1.3.x. You should either upgrade to 2.0.x-dev and use its implicit login event or replace the LoginListener with your own fos_user.security.login_manager service. (I did the latter because I'm using SonataUserBundle)

By request, here's the full solution for FOSUserBundle 1.3.x:

For implicit logins, add this to your services.yml:

fos_user.security.login_manager:
    class: Acme\Bundle\Security\LoginManager
    arguments: ['@security.context', '@security.user_checker', '@security.authentication.session_strategy', '@service_container', '@doctrine']

And make a file under Acme\Bundle\Security named LoginManager.php with the code:

<?php

namespace Acme\Bundle\Security;

use FOS\UserBundle\Security\LoginManagerInterface;

use FOS\UserBundle\Model\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;

use Doctrine\Bundle\DoctrineBundle\Registry as Doctrine; // for Symfony 2.1.0+

class LoginManager implements LoginManagerInterface
{
    private $securityContext;
    private $userChecker;
    private $sessionStrategy;
    private $container;
    private $em;

    public function __construct(SecurityContextInterface $context, UserCheckerInterface $userChecker,
                                SessionAuthenticationStrategyInterface $sessionStrategy,
                                ContainerInterface $container,
                                Doctrine $doctrine)
    {
        $this->securityContext = $context;
        $this->userChecker = $userChecker;
        $this->sessionStrategy = $sessionStrategy;
        $this->container = $container;
        $this->em = $doctrine->getManager();
    }

    final public function loginUser($firewallName, UserInterface $user, Response $response = null)
    {
        $this->userChecker->checkPostAuth($user);

        $token = $this->createToken($firewallName, $user);

        if ($this->container->isScopeActive('request')) {
            $this->sessionStrategy->onAuthentication($this->container->get('request'), $token);

            if (null !== $response) {
                $rememberMeServices = null;
                if ($this->container->has('security.authentication.rememberme.services.persistent.'.$firewallName)) {
                    $rememberMeServices = $this->container->get('security.authentication.rememberme.services.persistent.'.$firewallName);
                } elseif ($this->container->has('security.authentication.rememberme.services.simplehash.'.$firewallName)) {
                    $rememberMeServices = $this->container->get('security.authentication.rememberme.services.simplehash.'.$firewallName);
                }

                if ($rememberMeServices instanceof RememberMeServicesInterface) {
                    $rememberMeServices->loginSuccess($this->container->get('request'), $response, $token);
                }
            }
        }

        $this->securityContext->setToken($token);

        // Here's the custom part, we need to get the current session and associate the user with it
        $sessionId = $this->container->get('session')->getId();
        $user->setSessionId($sessionId);
        $this->em->persist($user);
        $this->em->flush();
    }

    protected function createToken($firewall, UserInterface $user)
    {
        return new UsernamePasswordToken($user, null, $firewall, $user->getRoles());
    }
}

For the more important Interactive Logins, you should also add this to your services.yml:

login_listener:
    class: Acme\Bundle\Listener\LoginListener
    arguments: ['@security.context', '@doctrine', '@service_container']
    tags:
        - { name: kernel.event_listener, event: security.interactive_login, method: onSecurityInteractiveLogin }

and the subsequent LoginListener.php for Interactive Login events:

<?php

namespace Acme\Bundle\Listener;

use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\DependencyInjection\Container;
use Doctrine\Bundle\DoctrineBundle\Registry as Doctrine; // for Symfony 2.1.0+

/**
 * Custom login listener.
 */
class LoginListener
{
    /** @var \Symfony\Component\Security\Core\SecurityContext */
    private $securityContext;

    /** @var \Doctrine\ORM\EntityManager */
    private $em;

    private $container;

    private $doc;

    /**
     * Constructor
     * 
     * @param SecurityContext $securityContext
     * @param Doctrine        $doctrine
     */
    public function __construct(SecurityContext $securityContext, Doctrine $doctrine, Container $container)
    {
        $this->securityContext = $securityContext;
        $this->doc = $doctrine;
        $this->em              = $doctrine->getManager();
        $this->container        = $container;
    }

    /**
     * Do the magic.
     * 
     * @param InteractiveLoginEvent $event
     */
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        if ($this->securityContext->isGranted('IS_AUTHENTICATED_FULLY')) {
            // user has just logged in
        }

        if ($this->securityContext->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
            // user has logged in using remember_me cookie
        }

        // First get that user object so we can work with it
        $user = $event->getAuthenticationToken()->getUser();

        // Get the current session and associate the user with it
        //$user->setSessionId($this->securityContext->getToken()->getCredentials());
        $sessionId = $this->container->get('session')->getId();
        $user->setSessionId($sessionId);
        $this->em->persist($user);
        $this->em->flush();

        // ...
    }
}
Electrotonus answered 19/8, 2014 at 16:1 Comment(12)
Great to hear I'm not the only one having this challenge. I like your solution using sessions instead of IP but I need to stick with FOSUserBundle ~1.3 though.Noctiluca
@Noctiluca If you're having trouble with that, I've made an edit with the code that I used to achieve the solution with FOSUserBundle 1.3.x. If you feel that something has (or hasn't) helped you, please don't hesitate to up (or down) vote it to help promote the problem and solution! Appreciate your comment about my use of sessions too - my logic was that multiple students could access the portal using a school's single IP address - session IDs are truly the smallest unit to represent a single session.Electrotonus
Great thanks for that update. Yes I forgot to up vote the topic. Will do it on the answer as well. Again, thank you I really appreciate sharing your solution.Noctiluca
Hi @Electrotonus I've already tried applying this solution but I got an error PHP Fatal error: Call to undefined method Symfony\\Component\\HttpKernel\\Event\\GetResponseEvent::isMasterRequest() could it be because I'm using an older version of Symfony (2.3)? So what I did is removed it, I'm not sure what is the purpose of that line. Lastly, Symfony doesn't seem to fire up the overridden LoginManager.php I tried adding a die() statement before persisting the session and it doesn't seem to be called. I noticed this because I'm not getting any value in my session_id column in my User table.Noctiluca
@Noctiluca Post your code. Did you add the LoginManager to your services.yml? isMasterRequest() is crucial so assets or other small files don't go through the same security listener. See this note and the note about using the getRequestType() method.Electrotonus
Hi @Electrotonus Thank you for your reply, yes I did add it in the services.yml as mentioned. Here are the contents of my files As you can see at the LoginManager.php I've added a die statement at line 69-70 but that doesn't get fired up.Noctiluca
@Noctiluca Could you try placing fos_user.security.login_manager above your rentalpos.session.listener? For some reason I remember that placement mattered significantly to SymfonyElectrotonus
@Noctiluca Also, where is your services.yml file located?Electrotonus
I've tried reordering as suggested but still the same. My services.yml is in the same directory as my config.yml which is app/configNoctiluca
Let us continue this discussion in chat.Electrotonus
@Electrotonus could you please update your solustion to the newer symfony version :)Dentelle
@Dentelle I don't work with Symfony anymore. Please feel free to suggest an edit or port your own new answer. I will community wiki it at that point.Electrotonus

© 2022 - 2024 — McMap. All rights reserved.