【问题标题】:How to add a custom field to the session table如何将自定义字段添加到会话表
【发布时间】:2015-01-22 20:37:39
【问题描述】:

我目前正在使用Symfony 2.1.8 和内置的PdoSessionHandler。

我想在会话表中添加一个user_id 字段来标识会话属于哪个(登录)用户。这个想法是我可以强制用户重新登录以破坏他的会话。在我的情况下,如果用户的权限被更新,就会发生这种情况。

我查看了内置 PdoSessionHandler,由于这些愚蠢的私有变量,您无法扩展。

所以我尝试创建一个新的(复制/粘贴)并添加我的列user_id。 现在,如果用户未登录(匿名用户),此列可以为空。

所以我想在处理程序的write方法中写这个user_id。该用户已经存储在$data 中,所以我想我可以检查该用户是否存在,获取其id 并将其添加到插入/更新查询中。

问题是$data 被编码了——我猜是 session_encode()——所以我不再确定这是处理我的新字段的最佳位置,但同时我看不到任何地方否则我可以这样做,因为我需要更新这个 MySQL 查询以插入新字段的值。

所以我的问题是:处理这个额外字段的最佳位置在哪里?以及如何设置这个 user_id 值?

另一方面,真正令人讨厌的是Symfony 在我每次登录或注销时都会创建一个新的cookie。所以数据库最终会得到很多记录(它总是同一个用户)。为什么Symfony 不是一直使用相同的 cookie 值?

【问题讨论】:

    标签: session pdo symfony-2.1


    【解决方案1】:

    您可以扩展 PdoSessionHandler(>=Symfony 2.1 的解决方案):

    namespace Acme\DemoBundle\HttpFoundation\Session\Storage\Handler;
    
    use Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler;
    use Symfony\Component\Security\Core\SecurityContext;
    
    class UserIdPdoSessionHandler extends PdoSessionHandler
    {
        /**
         * @var \PDO PDO instance.
         */
        private $pdo;
    
        /**
         * @var array Database options.
         */
        private $dbOptions;
    
        /**
         * @var SecurityContext
         */
        private $context;
    
        public function __construct(\PDO $pdo, array $dbOptions = array(), SecurityContext $context)
        {
            $this->pdo = $pdo;
            $this->dbOptions = array_merge(
                array('db_user_id_col' => 'user_id'),
                $dbOptions
            );
            $this->context = $context;
    
            parent::__construct($pdo, $dbOptions);
        }
    
        public function read($id)
        {
            // get table/columns
            $dbTable   = $this->dbOptions['db_table'];
            $dbDataCol = $this->dbOptions['db_data_col'];
            $dbIdCol   = $this->dbOptions['db_id_col'];
    
            try {
                $sql = "SELECT $dbDataCol FROM $dbTable WHERE $dbIdCol = :id";
    
                $stmt = $this->pdo->prepare($sql);
                $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
    
                $stmt->execute();
                // it is recommended to use fetchAll so that PDO can close the DB cursor
                // we anyway expect either no rows, or one row with one column. fetchColumn, seems to be buggy #4777
                $sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
    
                if (count($sessionRows) == 1) {
                    return base64_decode($sessionRows[0][0]);
                }
    
                // session does not exist, create it
                $this->createNewSession($id);
    
                return '';
            } catch (\PDOException $e) {
                throw new \RuntimeException(sprintf('PDOException was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e);
            }
        }
    
        /**
         * {@inheritDoc}
         */
        public function write($id, $data)
        {
            // get table/column
            $dbTable     = $this->dbOptions['db_table'];
            $dbDataCol   = $this->dbOptions['db_data_col'];
            $dbIdCol     = $this->dbOptions['db_id_col'];
            $dbTimeCol   = $this->dbOptions['db_time_col'];
            $dbUserIdCol = $this->dbOptions['db_user_id_col'];
    
            //session data can contain non binary safe characters so we need to encode it
            $encoded = base64_encode($data);
    
            $userId = $this->context->isGranted('IS_AUTHENTICATED_REMEMBERED') ?
                $this->context->getToken()->getUser()->getId() :
                null
            ;
    
            try {
                $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
    
                if ('mysql' === $driver) {
                    // MySQL would report $stmt->rowCount() = 0 on UPDATE when the data is left unchanged
                    // it could result in calling createNewSession() whereas the session already exists in
                    // the DB which would fail as the id is unique
                    $stmt = $this->pdo->prepare(
                        "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol, $dbUserIdCol) VALUES (:id, :data, :time, :user_id) " .
                        "ON DUPLICATE KEY UPDATE $dbDataCol = VALUES($dbDataCol), $dbTimeCol = VALUES($dbTimeCol)"
                    );
                    $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
                    $stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
                    $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
                    $stmt->bindParam(':user_id', $userId, \PDO::PARAM_STR);
                    $stmt->execute();
                } elseif ('oci' === $driver) {
                    $stmt = $this->pdo->prepare("MERGE INTO $dbTable USING DUAL ON($dbIdCol = :id) ".
                           "WHEN NOT MATCHED THEN INSERT ($dbIdCol, $dbDataCol, $dbTimeCol, $dbUserIdCol) VALUES (:id, :data, sysdate, :user_id) " .
                           "WHEN MATCHED THEN UPDATE SET $dbDataCol = :data WHERE $dbIdCol = :id");
    
                    $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
                    $stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
                    $stmt->bindParam(':user_id', $userId, \PDO::PARAM_STR);
                    $stmt->execute();
                } else {
                    $stmt = $this->pdo->prepare("UPDATE $dbTable SET $dbDataCol = :data, $dbTimeCol = :time WHERE $dbIdCol = :id");
                    $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
                    $stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
                    $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
                    $stmt->execute();
    
                    if (!$stmt->rowCount()) {
                        // No session exists in the database to update. This happens when we have called
                        // session_regenerate_id()
                        $this->createNewSession($id, $data);
                    }
                }
            } catch (\PDOException $e) {
                    throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e);
            }
    
            return true;
        }
    
        private function createNewSession($id, $data = '')
        {
            // get table/column
            $dbTable     = $this->dbOptions['db_table'];
            $dbDataCol   = $this->dbOptions['db_data_col'];
            $dbIdCol     = $this->dbOptions['db_id_col'];
            $dbTimeCol   = $this->dbOptions['db_time_col'];
            $dbUserIdCol = $this->dbOptions['db_user_id_col'];
    
            $userId = $this->context->isGranted('IS_AUTHENTICATED_REMEMBERED') ?
                $this->context->getToken()->getUser()->getId() :
                null
            ;
    
            $sql = "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol, $dbUserIdCol) VALUES (:id, :data, :time, :user_id)";
    
            //session data can contain non binary safe characters so we need to encode it
            $encoded = base64_encode($data);
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
            $stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
            $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
            $stmt->bindParam(':user_id', $userId, \PDO::PARAM_STR);
            $stmt->execute();
    
            return true;
        }
    }
    

    并配置会话以使用它:

    # config.yml
    framework:
        session:
            # ...
            handler_id: session.storage.custom
    
    parameters:
        pdo.db_options:
            db_table:       session
            db_id_col:      session_id
            db_data_col:    session_value
            db_time_col:    session_time
            db_user_id_col: session_user_id
    
    services:
        pdo:
            class: PDO
            arguments:
                dsn:      "mysql:host=%database_host%;dbname=%database_name%"
                user:     "%database_user%"
                password: "%database_password%"
    
        session.storage.custom:
            class: Acme\DemoBundle\HttpFoundation\Session\Storage\Handler\UserIdPdoSessionHandler
            arguments: [ @pdo, "%pdo.db_options%", @security.context ]
    

    【讨论】:

    • 您的代码中似乎有错误。构造函数应该以$this->dbOptions 开头,而不仅仅是dbOptions。
    • 应该是@security.context 而不是@security_context。
    【解决方案2】:

    我不确定修改会话是个好主意,您可以将会话 ID 存储在用户实体中,并在需要时删除它们。例如,通过这种方式,您可以确保只有用户一次只能使用一个会话登录。

    最简单的方法是使用登录监听器。

    将sessionId 字段添加到用户实体(或文档或您使用的任何持久性):

    // Acme/UserBundle/Entity/User.php
    namespace Acme\UserBundle\Entity;
    
    use Doctrine\ORM\Mapping as ORM;
    
    /**
     * ORM\Entity
     * @ORM\Table(name="fos_user")
     */
    class User {
      // ...
    
      /**
       * @ORM\Column(name="session_id", type="string")
       */
      private $sessionId;
    
      public function getSessionId() {
        return $this->sessionId;
      }
    
      public function setSessionId($sessionId = null) {
        $this->sessionId = $sessionId;
        return $this;
      }
    }
    

    并添加一个监听器:

    namespace Dbla\UserBundle\Listener;
    
    use Symfony\Component\HttpFoundation\Session;
    use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
    
    class LoginListener
    {
      protected $doctrine;
    
      protected $session;
    
      public function __construct(Session $session, Registry $doctrine)
      {
        $this->doctrine = $doctrine;
        $this->session = $session;
      }
    
      public function onLogin(InteractiveLoginEvent $event)
      {
        $user = $event->getAuthenticationToken()->getUser();
    
        if ($user) {
          $user->setSessionId($this->session->getId());
          $em = $this->doctrine->getEntityManager();
          $em->persist($user);
          $em->flush();
        }
      }
    }
    

    并将其添加为服务:

    services:
      acme_user.listsner.login:
        class: Acme\UserBundle\Listener\LoginListener
        arguments: [ @session, @doctrine ]
        tags:
          - { name: kernel.event_listener, event: security.interactive_login, method: onLogin }
    

    然后您可以简单地为用户删除会话:

    $users = []; // ... get user list
    $sessionIds = array_map(function($user) {
      return $user->getId();
    });
    if (count(sessionIds) > 0) {
      $sql = 'DELETE FROM session WHERE session_id IN (' . implode($sessionIds, ',') . ')';
      $entityManager->getConnection()->exec($sql);
    }
    foreach ($users as $user) {
      $user->setSessionId(null);
      $entityManager->persist($user);
    }
    $entityManager->flush();
    

    【讨论】:

    • 这不是一个坏主意,但是...在会话表中有user_id 我只需要执行一个查询:删除。另外,我更喜欢对会话表执行查询,这意味着存储的记录比用户表(理论上)要少得多,因为我应该只有登录用户的会话。我真正不喜欢的是每次我想销毁一个会话我都需要删除和更新用户。
    • 从用户中删除sessionId 也可以在一个查询中完成。但是通过这种方式,您可以注册侦听器以在删除用户会话时执行其他任务(修改User 实体)。它只刷新一次,因此对性能的影响很小。
    • 取决于您的应用程序字符,但通常匿名会话多于经过身份验证的会话。匿名用户也有会话。
    • 我可能最终会使用您的解决方案。我会开始赏金只是为了尝试有不同的观点。谢谢。
    • 我已经测试了您的解决方案。目前的问题是用户登录没有标准事件。您正在使用security. interactive_login,但这仅在登录时触发。我正在使用 FOSUserBundle 并且在以下情况下不会触发此事件:用户确认他的电子邮件或用户重置他的密码。在这两种情况下,他会自动登录并且不会触发任何事件。我现在正在调查。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-05-05
    • 2010-10-06
    • 2017-03-27
    • 2021-10-10
    • 2018-05-23
    相关资源
    最近更新 更多