8
votes

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.

1
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.Ryan Vincent
@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.sjagr

1 Answers

13
votes

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

        // ...
    }
}