cakephpのsecurityコンポーネントで二重投稿対策
以前、cakephpのsecurityコンポーネントを使ってハマったことを公開しましたが、具体的な実装例を公開します。
基本的に、securityコンポーネントは、不正があった場合に、blackHoleメソッドを呼び出す、という仕組みを提供してくれています。そのため、どの完了画面でもblackHoleメソッドで定義された共通の処理が実行されます。
が、ここにユーザビリティの観点で問題があると思い、少し工夫してみました。
何が問題か
CSRFに関しては、悪意ある行為なので、たとえばトップページにすべてリダイレクトしてしまうという共通処理で問題ないと思うのですが、二重投稿(リロードって言ってましたが言い方こっちに変えますw)は、ユーザが意図せずに実施してしまう場合があります。
たまたま画面がなかなか遷移せずに完了ボタンの2重クリックしてしまった
完了画面を開いたままブラウザを閉じて、また起動した際に完了画面に再びアクセスした
など、他にも、意図せず二重投稿してしまうシーンは考えられると思います。携帯サイトの場合、地下鉄で電波が途切れ途切れのなか、完了画面へのアクセスに失敗したとかは、1.の具体例ですね、これ、私がよくなりますw
親切な不正処理
そんなとき、トップページへリダイレクトさせるという処理では、不親切かと思います。ユーザは何故トップページに遷移したのか分からないし、そもそも投稿がうまくいったのかどうかすぐには分かりません。
そこで、二重投稿時は「すでに処理済であること」を伝えるのが一番親切かなと、思ってます。
機能ごとの処理済画面
さらにその「すでに処理済であること」を伝えた後の遷移は機能ごとに変わってくるはずなので、機能ごとに「処理済」画面を用意します。
実装
ポイントは3つで
リロードフラグを保持
blackHole呼び出し時に、リロードフラグを立てる
完了画面で、リロードフラグによって処理を振り分け
app_controller.php
まずはappコントローラにリロードフラグを保持させます。
個々のコントローラでもいいのですが、ほとんどのコントローラで利用するためここに記述しました。
class AppController extends Controller {
// リロードフラグ
var $isReload=false;
不正があった際に呼び出す関数を、設定します。今回は、_reloadStatusOnという名前にしました。
function beforeFilter() {
parent::beforeFilter();
・・・(省略)・・・
// エラー時のコールバック関数
$this->Security->blackHoleCallback = "_reloadStatusOn";
}
_reloadStatusOnメソッドの中身です。ポイントは、trueを返す点です。こうしないと、securityコンポーネント内の_generateTokenメソッドが呼び出されず、セッションが再生成されないため、常にblackholeが呼び出される状態になります。
// blackHoleの代わり
function _reloadStatusOn() {
$this->isReload = true;
// セッションを再生成させるために、trueを返しておく
return true;
}
二重投稿か否かを判定するメソッドを定義します。
// 二重投稿判定
function isReload() {
return $this->isReload;
}
これで、共通処理部分は完了です。
hoge_controller.php(実装したいコントローラ)
動作させたいコントローラで、securityコンポーネントを読み込みます。ここでは、securityコンポーネントはそのまま使えなかったので、継承させたMySecurityというコンポーネントを読み込みます。このコンポーネントは、共通で読むのが怖いので個々のコントローラで呼んでますw
class HogeController extends AppController {
var $components=array("MySecurity");
MySecurityは以前作成したのですが、そこからコメントを頂き、さらに_validatePostメソッドを追記したので、再掲します。
app/controller/components/my_security.php を作成し、中身は以下のようにします
App::import('Component', 'Security');
class MySecurityComponent extends SecurityComponent {
function initialize(&$controller,$settings = array()){
$controller->Security =& $this;
}
// fielsdListによる不正チェックを無視した_validatePost
function _validatePost(&$controller) {
if (empty($controller->data)) {
return true;
}
$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'] params['requested']) && $controller->params['requested'] === 1) {
if ($this->Session->check('_Token')) {
$tokenData = unserialize($this->Session->read('_Token'));
$controller->params['_Token'] = $tokenData;
}
return false;
}
$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;
}
}
_validatePostメソッドはチェックが厳しすぎて使えなかったので、無くても問題無いであろうfielsdListのチェックをはずしたものでオーバーライドしてます。
振り分け処理
あとはリロードステータスをみて、処理を振り分ける部分の実装になります。
class HogeController extends AppController {
var $components=array("MySecurity");
・・・(省略)・・・
function add(){
if($this->isReload()){
// 処理済画面
$this->render("add_reload_error");
}else{
// 完了処理
DBに登録したり、メール送ったり
// 通常完了画面
$this->render("add");
}
}
addアクションで実装する簡単な例を載せてみました。
まとめ
長々と書きましたが、app_controllerとmy_security.php ができてれば、あとは必要なコントローラでMySecurityを読んで、$this->isReload()で振り分けるだけになります。
これで、携帯で完了ボタン押したときに途中で電波が切れても問題ない・・・
と思ってたのですが、、、、なんと携帯の一部の機種でこれが動作しないことが判明w
これも解決したので、次のエントリーで書きたいと思います。