kakakakakku blog

Weekly Tech Blog: Keep on Learning!

FuelPHP のセッション情報に含まれる previous_id と rotated_session_id の用途を調べた

FuelPHP のセッション管理で少し気になる部分があったため,フレームワークの実装を読んでみた.公式ドキュメントには詳細に書かれていないけど,知っておくと良さそうな仕様を知ることができた.なお,今回は FuelPHP 1.8 を前提にしている.

設定

まず,セッション管理用の設定ファイル config/session.php の重要なパラメータを紹介する.パラメータは他にもあって,詳細は公式ドキュメントに書いてある.

match_ua

セッションを照合するときに,ユーザーエージェントが異なるとセッションを破棄するという設定で,デフォルトでは true になっている.基本的には true にしておくのが良さそう.

expiration_time

パラメータ名の通り,セッションを破棄するまでの秒数を意味している.デフォルトでは 7200秒 = 2時間 になっている.なお,ドキュメントには記載がないが,セッションストアに Memcached を使う場合 2592000秒 = 30日 が上限となり,自動的に丸められる.これは Memcached の仕様と言える.

// adjust the expiration time to the maximum possible for memcached
$this->config['expiration_time'] = min($this->config['expiration_time'], 2592000);

rotation_time

公式ドキュメントには,セッションハイジャックを防ぐために rotation_time で指定した間隔で自動的にセッションを更新すると書かれている.デフォルトでは 300秒 = 5分 となる.今回気になったのは,このローテーションの仕組みとなる.

ローテートの仕組みを確認した

ローテートの仕組みを確認するために Session_Driver クラスの rotate メソッドを読んでみた.

すると,セッションを破棄するのではなく $this->keys['previous_id'] に一度退避をしてから $this->keys['session_id'] に新規セッションを登録していることがわかった.previous_id の値を活用する実装はフレームワークの中で見つけることはできなかったけど,少なくとも「1世代前のセッションを保持している」という事実を知ることができた.

/**
 * force a session_id rotation
 *
 * @param  bool $force  if true, force a session id rotation
 * @return \Session_Driver
 */
public function rotate($force = true)
{
    // do we have a session?
    if ( ! empty($this->keys))
    {
        // existing session. need to rotate the session id?
        if ($force or ($this->config['rotation_time'] and $this->keys['created'] + $this->config['rotation_time'] <= $this->time->get_timestamp()))
        {
            // generate a new session id, and update the create timestamp
            $this->keys['previous_id'] = $this->keys['session_id'];
            $this->keys['session_id']  = $this->_new_session_id();
            $this->keys['created']         = $this->time->get_timestamp();
            $this->keys['updated']     = $this->keys['created'];
        }
    }
    return $this;
}

セッション取得の仕組みを確認した

次にセッション取得の仕組みを確認するために Session_Memcached クラスの read メソッドを読んでみた.今回は Memcached を前提にしている.

詳細は割愛するとして,気になったのは以下の部分だった.

if (isset($payload['rotated_session_id']))
{
    $payload = $this->_read_memcached($payload['rotated_session_id']);

Memcached を dump すると,セッション情報とは別に,以下のように破棄されたはずのセッション情報だけを保持しているキーが存在する.先ほどの例で言うとキーの値が previous_id と一致し,rotated_session_id の値が session_id と一致している.ようするに,1世代前のセッションから,最新のセッションを引き戻すことができるという意味になる.サンプルコードを使って動作確認をしてみたら,確かにそのような動きになっていた.この仕様は「セッションハイジャックを防ぐ」という目的とコンフリクトしているのではないか?どうなんだろう.

add key_11111111111111111111111111111111 0 1483196400 72
a:1:{s:18:"rotated_session_id";s:32:"22222222222222222222222222222222";}

add key_22222222222222222222222222222222 0 1483197000 72
a:1:{s:18:"rotated_session_id";s:32:"33333333333333333333333333333333";}

他にも,セッション情報の updated の値を見て,既に有効期限を過ぎている場合はエラーを返していたり,match_ipmatch_ua の一致確認をしていたり,読んでいて非常に勉強になるクラスだった.

/**
 * read the session
 *
 * @param  bool $force  set to true if we want to force a new session to be created
 * @return \Session_Driver
 */
public function read($force = false)
{
    // initialize the session
    $this->data = array();
    $this->keys = array();
    $this->flash = array();
    // get the session cookie
    $cookie = $this->_get_cookie();
    // if a cookie was present, find the session record
    if ($cookie and ! $force and isset($cookie[0]))
    {
        // read the session file
        $payload = $this->_read_memcached($cookie[0]);
        if ($payload === false)
        {
            // cookie present, but session record missing. force creation of a new session
            return $this->read(true);
        }
        // unpack the payload
        $payload = $this->_unserialize($payload);
        // session referral?
        if (isset($payload['rotated_session_id']))
        {
            $payload = $this->_read_memcached($payload['rotated_session_id']);
            if ($payload === false)
            {
                // cookie present, but session record missing. force creation of a new session
                return $this->read(true);
            }
            else
            {
                // unpack the payload
                $payload = $this->_unserialize($payload);
            }
        }
        if ( ! isset($payload[0]) or ! is_array($payload[0]))
        {
            logger('DEBUG', 'Error: not a valid memcached payload!');
        }
        elseif ($payload[0]['updated'] + $this->config['expiration_time'] <= $this->time->get_timestamp())
        {
            logger('DEBUG', 'Error: session id has expired!');
        }
        elseif ($this->config['match_ip'] and $payload[0]['ip_hash'] !== md5(\Input::ip().\Input::real_ip()))
        {
            logger('DEBUG', 'Error: IP address in the session doesn\'t match this requests source IP!');
        }
        elseif ($this->config['match_ua'] and $payload[0]['user_agent'] !== \Input::user_agent())
        {
            logger('DEBUG', 'Error: User agent in the session doesn\'t match the browsers user agent string!');
        }
        else
        {
            // session is valid, retrieve the rest of the payload
            if (isset($payload[0]) and is_array($payload[0]))
            {
                $this->keys  = $payload[0];
            }
            if (isset($payload[1]) and is_array($payload[1]))
            {
                $this->data  = $payload[1];
            }
            if (isset($payload[2]) and is_array($payload[2]))
            {
                $this->flash = $payload[2];
            }
        }
    }
    return parent::read();
}

類似した Issue を発見した

2011年の Issue で,FuelPHP のバージョンも大きく違うけど,コメントを読む限りは previous_id の扱いは昔から大きく変わっていないように感じた.論点としては rotation_time を超えた Ajax を呼び出したときを考慮しているという話だけど,そこまで長時間走る Ajax があったとしても,例えば php-fpm の request_terminate_timeout を設定していれば自動的にリクエストがタイムアウトするだろうし,設定で回避する方法もあるのでは?

github.com

memcached-tool

Memcached のデータを見るために memcached-tool を使った.実装を読むと dump のデータ構造も理解することができた.

$ memcached-tool localhost:11211 dump > memcached.dump

まとめ

  • FuelPHP 1.8 の実装を読んでみた
  • セッションをローテートするときに previous_id として「1世代前」のセッションを保持している
  • セッションを取得するときに rotated_session_id の値を使うため「1世代前」のセッションから「最新」のセッションを引き戻すことができる
  • FuelPHP の Issue を見たら,長時間走る Ajax の考慮という説明がされていた

関連記事

d.hatena.ne.jp