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.
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.
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 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.
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();
// ...
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With