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_ip
と match_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
を設定していれば自動的にリクエストがタイムアウトするだろうし,設定で回避する方法もあるのでは?
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 の考慮という説明がされていた