Запретить одновременные сеансы пользователей в Symfony2

Цель

Мы предоставляем клиенту решение для системы практики с множественным выбором, в которой студенты платят за ежемесячное членство, чтобы проверить свои знания и подготовиться к медицинским экзаменам. Основная проблема с предоставлением этого решения в Symfony2 заключается в том, что студенты могут купить одну подписку, поделиться своими учетными данными с одноклассниками и коллегами и разделить стоимость подписки на несколько одновременных входов в систему.

Чтобы свести к минимуму эту проблему, мы не хотим поддерживать более одного одновременных сеансов в нашем проекте Symfony2.

Исследование

Огромное количество Google-фу привело меня к этой редкой групповой теме Google где OP кратко сказали использовать PdoSessionHandler для хранения сеансов в базе данных.

Вот еще один вопрос SO, где кто-то еще работал над тем же самым делом, но без объяснения < em>как это сделать.

Прогресс до сих пор

Я реализовал этот обработчик для проекта, и в настоящее время у меня есть прослушиватель security.interactive_login, который сохраняет полученный идентификатор сеанса с пользователем в базе данных. Прогресс здесь

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 не завершится, поэтому идентификатор пользователя никогда не будет сохранен в таблице сеансов. Как я могу заставить это работать? Где я могу хранить идентификатор пользователя в таблице сеансов?

В качестве альтернативы, есть ли лучший способ сделать это? Это оказалось чрезвычайно разочаровывающим с Symfony, поскольку я не думаю, что он когда-либо был разработан для эксклюзивных однопользовательских сеансов для каждого пользователя.


person sjagr    schedule 18.08.2014    source источник
comment
Итак, у вас есть следующие структуры в вашей базе данных: студенты могут купить одну подписку, поделиться своими учетными данными с одноклассниками и коллегами и разделить стоимость подписки на несколько одновременных входов в систему. Пожалуйста, покажите таблицы базы данных, которые реализуют эту структуру. какие запросы вы используете для их обслуживания? Проблема с использованием сеансов заключается в том, что «они временные» и нигде не записываются на постоянной основе. Особенно с группой «пользователей», которые могут быть не активны одновременно.   -  person Ryan Vincent    schedule 19.08.2014
comment
@RyanVincent Таблица User создана из пользовательского пакета Sonata (который расширяет FOSUserBundle). Все это поведение по умолчанию - мне не нужно показывать вам сущности и функции, которые находятся в FOSUserBundle и Sonata.   -  person sjagr    schedule 19.08.2014


Ответы (1)


Я решил свою собственную проблему, но оставлю вопрос открытым для диалога (если таковой имеется), прежде чем я смогу принять свой собственный ответ.

Я создал прослушиватель kernel.request, который будет проверять текущий идентификатор сеанса пользователя с последним идентификатором сеанса, связанным с пользователем, при каждом входе в систему.

Вот код:

<?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 с именем 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 для событий интерактивного входа:

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

        // ...
    }
}
person sjagr    schedule 19.08.2014
comment
Приятно слышать, что я не единственный, у кого есть эта проблема. Мне нравится ваше решение с использованием сеансов вместо IP, но мне нужно придерживаться FOSUserBundle ~ 1.3. - person JohnnyQ; 15.09.2014
comment
@JohnnyQ Если у вас возникли проблемы с этим, я внес изменения в код, который использовал для достижения решения с помощью FOSUserBundle 1.3.x. Если вы чувствуете, что что-то вам помогло (или не помогло), пожалуйста, не стесняйтесь голосовать за (или против) это, чтобы способствовать продвижению проблемы и решения! Также ценю ваш комментарий по поводу моего использования сеансов - моя логика заключалась в том, что несколько учащихся могут получить доступ к порталу, используя один школьный IP-адрес - идентификаторы сеансов действительно являются наименьшей единицей для представления одного сеанса. - person sjagr; 15.09.2014
comment
Большое спасибо за это обновление. Да, я забыл проголосовать за тему. Сделаю это и в ответе. Еще раз, спасибо, я очень ценю то, что поделился вашим решением. - person JohnnyQ; 16.09.2014
comment
Привет, @sjagr. Я уже пытался применить это решение, но получил ошибку PHP Fatal error: Call to undefined method Symfony\\Component\\HttpKernel\\Event\\GetResponseEvent::isMasterRequest(), может ли это быть из-за того, что я использую более старую версию Symfony (2.3)? Итак, что я сделал, так это удалил его, я не уверен, какова цель этой строки. Наконец, Symfony, похоже, не запускает переопределенный LoginManager.php. Я попытался добавить оператор die() перед сохранением сеанса, и он, похоже, не вызывается. Я заметил это, потому что я не получаю никакого значения в моем столбце session_id в моей таблице User. - person JohnnyQ; 23.09.2014
comment
@JohnnyQ Опубликуйте свой код. Вы добавили LoginManager к services.yml? isMasterRequest() имеет решающее значение, поэтому активы или другие небольшие файлы не проходят через один и тот же прослушиватель безопасности. см. это примечание и примечание об использовании getRequestType() метод. - person sjagr; 23.09.2014
comment
Привет @sjagr Спасибо за ваш ответ, да, я добавил его в services.yml, как уже упоминалось. Вот содержимое моих файлов Как вы можете видеть в LoginManager.php, я добавил die оператор в строке 69-70, но он не запускается. - person JohnnyQ; 23.09.2014
comment
@JohnnyQ Не могли бы вы попробовать поставить fos_user.security.login_manager над rentalpos.session.listener? По какой-то причине я помню, что размещение имело большое значение для Symfony. - person sjagr; 23.09.2014
comment
@JohnnyQ Кроме того, где находится ваш файл services.yml? - person sjagr; 23.09.2014
comment
Я попытался изменить порядок, как было предложено, но все равно. Мой services.yml находится в том же каталоге, что и мой config.yml, то есть app/config. - person JohnnyQ; 23.09.2014
comment
Давайте продолжим обсуждение в чате. - person sjagr; 23.09.2014
comment
@sjagr, не могли бы вы обновить свое решение до более новой версии Symfony :) - person Blueblazer172; 27.03.2017
comment
@ Blueblazer172 Я больше не работаю с Symfony. Пожалуйста, не стесняйтесь предлагать редактирование или портировать свой новый ответ. В этот момент я опубликую вики-сообщество. - person sjagr; 28.03.2017