cakephp::securityコンポーネントを利用するときの注意
フォームに、ワンタイムトークンを実装することで、リロード対策とCSRF対策が同時に可能です。その実装に、securityコンポーネントという便利なコンポーネントが用意されているのですが、利用の際に嵌ったのでメモ。
なおsecurityコンポーネントを設置して「効きすぎてPOSTできなくなった」というのは見るのですが、「うごかねぇ(リロードできちゃう)」という人向けな内容になります。
まず
一番よくまとまっているのが、こちら
Sun Limited Mt.:CakePHP Security コンポーネントのまとめ
で、参考にさせてもらい設置しました。
ただし、なぜかview側に
<?php echo $html->formTag(); ?>
を記述しなくても、トークンが書き出されました。何故か分かりませんがw
想定
画面遷移ですが、
入力画面(トークンの発行)
完了画面(トークンのチェックOK)
リロード
完了画面(トークンのチェックNG(=blackholeメソッド呼び出し))
という予定だったのですが、リロードしてもチェックがOKのままでした。
そこでsecurityコンポーネントのソースを追ってみました。
ここから長いので先に結論から。
(追記:以下の修正はコアコンポーネントを修正しているため非推奨です。このエントリーでそれを解決しています。)
/cake/libs/controller/components/security.php の651行目をコメントアウトして、解決しました。
if ($valid) {
// $token['key'] = $tokenData['key'];
}
何故そうなったかは、ソースを追って解説していきます。securityコンポーネントは多機能なので、特に今回は関係のあるトークン周りの説明になります。
トークンの発行
まず_generateTokenメソッドでトークンの発行が行われていました。ソースはここ(/cake/libs/controller/components/security.php 620行目)
function _generateToken(&$controller) {
(・・・省略・・・)
$authKey = Security::generateAuthKey();
$expires = strtotime('+' . Security::inactiveMins() . ' minutes');
$token = array(
'key' => $authKey,
'expires' => $expires,
'allowedControllers' => $this->allowedControllers,
'allowedActions' => $this->allowedActions,
'disabledFields' => $this->disabledFields
);
if (!isset($controller->data)) {
$controller->data = array();
}
if ($this->Session->check('_Token')) {
$tokenData = unserialize($this->Session->read('_Token'));
$valid = (
isset($tokenData['expires']) &&
$tokenData['expires'] > time() &&
isset($tokenData['key'])
);
if ($valid) {
$token['key'] = $tokenData['key'];
}
}
$controller->params['_Token'] = $token;
$this->Session->write('_Token', serialize($token));
return true;
}
細かく見ていきます。
$authKey = Security::generateAuthKey();
$expires = strtotime('+' . Security::inactiveMins() . ' minutes');
$token = array(
'key' => $authKey,
'expires' => $expires,
'allowedControllers' => $this->allowedControllers,
'allowedActions' => $this->allowedActions,
'disabledFields' => $this->disabledFields
);
if (!isset($controller->data)) {
$controller->data = array();
}
こまごまありますが、大事なのは、$expiresにSecurity::inactiveMins()が設定されていること(後述)で、app/config.php のsecurity.levelによって値が変わります。(今回はどの値でも問題がありましたw)
$tokenData = unserialize($this->Session->read('_Token'));
$tokenDataにセッションの_Tokenキーが(アンシリアライズされて)セットされます。
$valid = (
isset($tokenData['expires']) &&
$tokenData['expires'] > time() &&
isset($tokenData['key'])
);
$tokenData[’expires’]と今の時間を比較し、その結果を$validにセット
if ($valid) {
$token['key'] = $tokenData['key'];
}
$validがtrueなら、$token[‘key’]を$tokenData[‘key’]で上書きしています。つまり、
有効期限が今より先なら、保持していたセッションのkeyで上書きしています。
$controller->params['_Token'] = $token;
$this->Session->write('_Token', serialize($token));
で、$tokenでセッションの_Tokenをセットしています。
トークンのチェック
トークンのチェックは_validatePostメソッドで実行されてるようで、(540行目~)
function _validatePost(&$controller) {
(・・・省略・・・)
$data = $controller->data;
if (!isset($data['_Token']) || !isset($data['_Token']['fields']) || !isset($data['_Token']['key'])) {
return false;
}
$token = $data['_Token']['key'];
if ($this->Session->check('_Token')) {
$tokenData = unserialize($this->Session->read('_Token'));
if ($tokenData['expires'] < time() || $tokenData['key'] !== $token) {
return false;
}
}
(・・・省略・・・)
}
細かく見ていくと、まず,
function _validatePost(&$controller) {
$data = $controller->data;
if (!isset($data['_Token']) || !isset($data['_Token']['fields']) || !isset($data['_Token']['key'])) {
return false;
}
$token = $data['_Token']['key'];
$tokenに$controller->dataの[’_Token’][‘key’]キーがセットされています。つまりPOSTで飛んでくるトークンですね。
if ($this->Session->check('_Token')) {
$tokenData = unserialize($this->Session->read('_Token'));
if ($tokenData['expires'] < time() || $tokenData['key'] !== $token) {
return false;
}
}
セッションにトークンがあれば、それを読んで$tokenDataに(アンシリアライズして)セットします。で、
$tokenData[’expires’]が今より前か、または、$tokenData[‘key’]が$tokenと一致しなければ、falseが返ります。
で、falseが返るとblackHoleメソッドが呼ばれる流れになってます。
で、何故動作しない(↑でfalseにならない)のか
_validatePostメソッドで比較しているトークン値は、
入力画面から飛んできたPOSTのトークン
sessionのトークン
なので、リロードした際にはPOSTのトークンは同じ値が飛んできて、sessionのトークンが変わっていればいいはずです。
しかし、_generateTokenメソッドでは、変わってほしいsessionのトークンがexpiresとtime()の比較の結果、今のsessionのトークンで上書きされてしまいます。なので、expiresで設定された時間(たとえば10分)を過ぎてから、リロードすれば期待通りの動きになりますが、その期間内のリロードはすべて受け入れる形になってました。。。なんで。。。
再び結論
ということで、一番影響なさそうな、以下の部分(sessionトークンを上書きする部分)をコメントアウトして、対応しました。
if ($valid) {
// $token['key'] = $tokenData['key'];
}
うーん、使い方が間違ってるんですかね?w こんなことしないと使えないとは思えない。。。
もっと普通のw方法があればぜひ教えて下さい。。。