防止在Symfony2中同时进行用户会话

时间:2014-08-18 17:20:14

标签: php symfony-2.5

目标

我们为客户提供多选实践系统的解决方案,学生每月支付会员费用,以测试他们的知识并准备医疗相关的考试。在Symfony2中提供此解决方案的一个主要问题是,学生可以购买一个订阅,与同学和同事共享他们的凭据,并通过多个并发登录分配订阅成本。

为了最大限度地减少此问题,我们希望阻止在Symfony2项目中维护多个同步会话。

研究

大量的Google-fu引导我进入这个稀疏Google group thread,其中OP被简要告知使用PdoSessionHandler将会话存储在数据库中。

以下是another SO question where someone else worked around the same thing,但没有解释如何这样做。

到目前为止的进展

我已经为项目实现了这个处理程序,并且当前有一个security.interactive_login侦听器,它将结果会话ID与User存储在数据库中。进展在这里

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
}

问题

会话不会从PdoSessionHandler存储到数据库中,直到 security.interactive_login侦听器完成后,因此用户ID永远不会最终存储在会话表中。 我该怎么做才能做到这一点?我在哪里可以在会话表中拥有用户ID存储?

或者,有更好的方法来解决这个问题吗?这对Symfony来说非常令人沮丧,因为我认为它并不是为每个用户设计独家的单用户会话。

1 个答案:

答案 0 :(得分:12)

我已经解决了我自己的问题,但在我能够接受我自己的答案之前,会将问题留待对话(如果有的话)。

我创建了一个kernel.request侦听器,用于在每次登录时使用与用户关联的最新会话ID检查用户的当前会话ID。

以下是代码:

<?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;
                }
            }
        }
    }
}

services.yml条目:

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 }

值得注意的是,当我意识到我之前将imcq.session.listener命名为session_listener时,我花了一大笔时间,想知道为什么我的听众会让我的应用程序中断。事实证明Symfony(或其他一些软件包)已经在使用该名称,因此我压倒了它的行为。

小心!这将破坏FOSUserBundle 1.3.x上的隐式登录功能。您应升级到2.0.x-dev并使用其隐式登录事件,或将LoginListener替换为您自己的fos_user.security.login_manager服务。 (我做了后者,因为我使用的是SonataUserBundle)

根据要求,这里是FOSUserBundle 1.3.x的完整解决方案:

对于隐式登录,请将其添加到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']

使用代码

在名为Acme\Bundle\Security的{​​{1}}下创建一个文件
LoginManager.php

对于更重要的互动登录,您还应将其添加到<?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()); } }

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 } 用于交互式登录事件:

LoginListener.php