k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

マイクロフレームワークをつくろう - Pimpleの上に(Symfony HttpFoundationでフラッシュメッセージとCSRF対策編)

Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。

コードはWindows版 PHP5.4 ビルトインWebサーバにて動作確認しています。

Symfony HttpFoundation Sessionクラスを導入

昔はユースケース毎に 入力 → 確認(入力内容を表示) → 完了(処理結果を表示) という流れで専用画面を用意することが多かったのですが、最近は確認・完了とも専用画面なし、処理結果は任意の遷移元にリダイレクトして簡単なメッセージのみ表示という実装が増えてます。

ダイレクト先で表示するためのメッセージを定義するためには、いわゆるセッション変数を使うわけですが、すでにSymfony HttpFoundationコンポーネントを導入済みなので、同梱されているSessionクラスを使って実装します。

app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

// …中略…

use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler;

$app = new Application();

// アプリケーション設定オブジェクトを生成
$app->config = $app->share(function(Application $app) {
    $config = new Configuration(array(
        'debug'      => true,
        'app_root'   => __DIR__,
        'web_root'   => realpath(__DIR__ . '/../www'),
        'log_dir'    => __DIR__ . DIRECTORY_SEPARATOR . 'log',
        'log_file'   => date('Y-m') . '.log',
        'error_log'  => null,
        'error_view' => 'error.html',
    ));
    $config['error_log'] = function($config) {
        return $config['log_dir'] . DIRECTORY_SEPARATOR . $config['log_file'];
    };
    return $config;
});

// …中略…

// セッションオブジェクトを生成
$app->session = $app->share(function(Application $app) {
    return new Session(
        new NativeSessionStorage(
            array(
                'use_only_cookies' => 1,
                'cookie_httponly'  => 1,
                'entropy_length'   => 2048,
                'entropy_file'     => '/dev/urandom',
                'hash_function'    => 1,
                'hash_bits_per_character' => 5,
            ),
            new NativeFileSessionHandler($app->config->app_root . DIRECTORY_SEPARATOR . 'session')
        )
    );
});

// …中略…

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {
    error_reporting(E_ALL);
    set_error_handler(function($errno, $errstr, $errfile, $errline) {
        throw new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    });

    // セッション開始
    $app->session->start();

    try {
        $method = $app->request->getMethod();
        $handlerName = 'on' . ucfirst(strtolower($method));
        if (!$app->offsetExists($handlerName)) {
            throw new HttpException(405);
        }
        $response = $app->{$handlerName}($app, $method);
    } catch (\Exception $e) {
        error_log(sprintf("[%s] %s\n", date('Y-m-d H:i:s'), (string)$e), 3, $app->config->error_log);
        $statusCode = 500;
        $statusMessage = null;
        $message = null;
        $headers = array();
        if ($e instanceof HttpException) {
            $statusCode = $e->getCode();
            $statusMessage = $e->getStatusMessage();
            $message = $e->getMessage();
            $headers = $e->getHeaders();
        }
        $response = $app->render($app->config->error_view,
            array(
                'title' => 'エラーが発生しました',
                'statusMessage' => $statusMessage,
                'message' => $message,
                'exception' => $e,
                'exception_class' => get_class($e),
            ),
            $statusCode,
            $headers
        );
    }
    $response->send();
});

return $app;

こんな感じで、まずは $app->session でセッションオブジェクトを取得できるようにして、アプリケーションの実行時に自動的にセッションを開始します。

セッション変数はPHPのデフォルト実装と同様にファイルで管理しますが、アプリケーションのルート以下に専用ディレクトリ app/session を作成して、そちらに保存する設定としました。

(広く利用されているSymfonyの標準コンポーネントだけあって、この辺りの設定は利用ケースに合わせて様々なクラスが用意されているようです)

この状態でビルトインWebサーバを起動してアプリケーションを動かすと、確かにセッションファイルが作成されました。

NativeSessionStorageクラスやNativeFileSessionHandlerクラスを覗いてみると、ini_get(), ini_set()やsession関数を利用してPHPのセッション機構を活かす実装がなされています。

NativeSessionStorageではコンストラクタの第1引数でsessionディレクティブの値を指定すればそれが使われて、指定しなければPHPの設定(php.iniとか.htaccess)がそのまま使われるようです。

また、セキュリティのためかsession.use_cookiesが強制的に有効(明示的に無効を指定しない限り)になるほか、クライアントキャッシュ制御用のHTTPヘッダ出力が強制的に無効(Responseクラスとの兼ね合いのため?)にされたり、シャットダウン関数としてsession_write_close()が登録されます。

NativeFileSessionHandlerではコンストラクタの第1引数が指定されていれば session.save_path にそれを設定してくれます。

なので、上記サンプルの場合はPHPの設定を指定したオプションで上書きして、セッションファイルの保存先はアプリケーション個別のディレクトリに変更しています。

(entropy_file=/dev/urandomとしていますがWindowsでは無視され、PHP 5.3.3以降でentropy_lengthに0以外の数値を設定した場合にWindows Random APIが利用されるとのこと。PHPマニュアル

アプリケーションオブジェクトにフラッシュメッセージの追加/取得用クラスを定義

Symfony Sessionクラスには、前述のリダイレクト先で表示するメッセージなど、一時的なデータの保存に最適な FlashBag という仕組みがありますので、これを利用します。

ただ、そのまま $app->session->getFlashBag()->add() とか $app->session->getFlashBag()->get() などと利用側スクリプトに書いてしまうと、せっかくこれまで Symfony HttpFoundation コンポーネントへの依存を隠していたのが台無しになってしまいます。

いつかSymfonyやめて他のライブラリ使いたくなった時にも修正箇所が増えないよう、アプリケーションオブジェクトにフラッシュメッセージの出し入れ専用の関数なりオブジェクトを登録しようと考えましたが、Rendererのようにいちいち専用のインタフェースやクラスを定義するのもちょっと面倒かも…。

というわけで、手抜き用の汎用クラス DataObject を作成しました。

(なんだか怒られそうなクラス名ですが、気にしたら負けです)

src/Acme/DataObject.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme;

/**
 * データオブジェクト
 *
 * @author k.holy74@gmail.com
 */
class DataObject implements \ArrayAccess, \IteratorAggregate
{

    /**
     * @var array 属性の配列
     */
    private $attributes;

    /**
     * コンストラクタ
     *
     * @param array 属性の配列
     */
    public function __construct($attributes = array())
    {
        if (!is_array($attributes) && !($attributes instanceof \Traversable)) {
            throw new \InvalidArgumentException(
                sprintf('The attributes is not Array and not Traversable. type:"%s"',
                    (is_object($attributes)) ? get_class($attributes) : gettype($attributes)
                )
            );
        }
        $this->attributes = array();
        foreach ($attributes as $name => $value) {
            $this->attributes[$name] = $value;
        }
    }

    /**
     * ArrayAccess::offsetExists()
     *
     * @param mixed
     * @return bool
     */
    public function offsetExists($offset)
    {
        return array_key_exists($offset, $this->attributes);
    }

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($offset)
    {
        if (!array_key_exists($offset, $this->attributes)) {
            return null;
        }
        return $this->attributes[$offset];
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($offset, $value)
    {
        $this->attributes[$offset] = $value;
    }

    /**
     * ArrayAccess::offsetUnset()
     *
     * @param mixed
     */
    public function offsetUnset($offset)
    {
        if (array_key_exists($offset, $this->attributes)) {
            $this->attributes[$offset] = null;
        }
    }

    /**
     * magic setter
     *
     * @param string 属性名
     * @param mixed 属性値
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * magic getter
     *
     * @param string 属性名
     */
    public function __get($name)
    {
        return $this->offsetGet($name);
    }

    /**
     * magic call method
     *
     * @param string
     * @param array
     */
    public function __call($name, $args)
    {
        if (array_key_exists($name, $this->attributes)) {
            $value = $this->attributes[$name];
            if (is_callable($value)) {
                return call_user_func_array($value, $args);
            }
            return $value;
        }
        throw new \BadMethodCallException(
            sprintf('Undefined Method "%s" called.', $name)
        );
    }

    /**
     * IteratorAggregate::getIterator()
     *
     * @return \ArrayIterator
     */
    public function getIterator()
    {
        return new \ArrayIterator($this->attributes);
    }

}

ほとんど前回の Configuration クラスと同じなんですが、コンストラクタで自動的に配列を入れ子にしないのと、オブジェクト生成後にいくらでもプロパティ(というか要素というか)を追加できるのと、存在しないキーで取得しようとしてもしれっとNULLを返すところが違います。

これを使って、何かインタフェースに則ったクラスのフリをしつつアプリケーションオブジェクト経由でフラッシュメッセージを利用できるようにしてみます。

アプリケーション共通初期処理のコードから抜粋 app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\DataObject;

// …中略…

// フラッシュメッセージ
$app->flash = $app->share(function(Application $app) {
    return new DataObject(array(
        'add' => function($name, $message) use ($app) {
            $app->session->getFlashBag()->add($name, $message);
        },
        'has' => function($name) use ($app) {
            return $app->session->getFlashBag()->has($name);
        },
        'get' => function($name) use ($app) {
            return $app->session->getFlashBag()->get($name);
        },

        // Error
        'addError' => function($message) use ($app) {
            $app->flash->add('error', $message);
        },
        'hasError' => function() use ($app) {
            return $app->flash->has('error');
        },
        'getError' => function() use ($app) {
            return $app->flash->get('error');
        },

        // Alert
        'addAlert' => function($message) use ($app) {
            $app->flash->add('alert', $message);
        },
        'hasAlert' => function() use ($app) {
            return $app->flash->has('alert');
        },
        'getAlert' => function() use ($app) {
            return $app->flash->get('alert');
        },

        // Success
        'addSuccess' => function($message) use ($app) {
            $app->flash->add('success', $message);
        },
        'hasSuccess' => function() use ($app) {
            return $app->flash->has('success');
        },
        'getSuccess' => function() use ($app) {
            return $app->flash->get('success');
        },

        // Info
        'addInfo' => function($message) use ($app) {
            $app->flash->add('info', $message);
        },
        'hasInfo' => function() use ($app) {
            return $app->flash->has('info');
        },
        'getInfo' => function() use ($app) {
            return $app->flash->get('info');
        },
    ));
});

// …以下略…

これで、アプリケーションオブジェクト利用側からは $app->flash->add($name, $message) や $app->flash->has($name) や $app->flash->get($name, $default) といったインタフェースでフラッシュメッセージを利用できます。

また、フラッシュメッセージは取得時にセッション変数から破棄されてしまうため、テンプレートファイルから直接参照した方が良いとの判断から、 $app->flash->addError(), $app->flash->hasError(), $app->flash->getError() といった形のメソッドも実装しています。

(Bootstrapの仕様に合わせるために Error, Alert, Success, Info と関数を定義するのもなんだか微妙な感じはしますが…)

こういう手抜き実装を許してくれるPimpleさんって本当に包容力があって素敵です。

シリーズ記事の最初に書いた「実は自分が惚れたのはSilexではなくて、その母体たるPimpleだった」と考える理由は、まさにこの点に集約されています。

トークンオブジェクトによるCSRF対策

さて、せっかくセッション機構を導入しましたので、この際CSRF対策としてよく利用されるトランザクショントークンも実装してみます。

再びアプリケーション共通初期処理のコードから抜粋

app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\DataObject;

// …中略…

// アプリケーション設定オブジェクトを生成
$app->config = $app->share(function(Application $app) {
    $config = new Configuration(array(
        'debug'      => true,
        'app_root'   => __DIR__,
        'web_root'   => realpath(__DIR__ . '/../www'),
        'log_dir'    => __DIR__ . DIRECTORY_SEPARATOR . 'log',
        'log_file'   => date('Y-m') . '.log',
        'error_log'  => null,
        'error_view' => 'error.html',
        'secret_key' => 'CCi:wYD-4:iV:@X%1zun[Y@:',
    ));
    $config['error_log'] = function($config) {
        return $config['log_dir'] . DIRECTORY_SEPARATOR . $config['log_file'];
    };
    return $config;
});

// …中略…

// トークンオブジェクトを生成
$app->token = $app->share(function(Application $app) {
    return new DataObject(array(
        'name'  => function() use ($app) {
            return sha1($app->config->secret_key);
        },
        'value' => function() use ($app) {
            return $app->session->getId();
        },
        'validate' => function($value) use ($app) {
            if (is_null($value)) {
                return false;
            }
            return ($value === $app->token->value());
        },
    ));
});

// CSRFトークンの検証
$app->csrfVerify = $app->protect(function($method) use ($app) {
    return $app->token->validate($app->findVar($method, $app->token->name()));
});

// …中略…

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {

    error_reporting(E_ALL);
    set_error_handler(function($errno, $errstr, $errfile, $errline) {
        throw new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    });

    // セッション開始
    $app->session->start();

    try {
        $method = $app->request->getMethod();
        $handlerName = 'on' . ucfirst(strtolower($method));
        if (!$app->offsetExists($handlerName)) {
            throw new HttpException(405);
        }
        $response = $app->{$handlerName}($app, $method);
    } catch (\Exception $e) {
        error_log(sprintf("[%s] %s\n", date('Y-m-d H:i:s'), (string)$e), 3, $app->config->error_log);
        $statusCode = 500;
        $statusMessage = null;
        $message = null;
        $headers = array();
        if ($e instanceof HttpException) {
            $statusCode = $e->getCode();
            $statusMessage = $e->getStatusMessage();
            $message = $e->getMessage();
            $headers = $e->getHeaders();
        }
        $response = $app->render($app->config->error_view,
            array(
                'title' => 'エラーが発生しました',
                'statusMessage' => $statusMessage,
                'message' => $message,
                'exception' => $e,
                'exception_class' => get_class($e),
            ),
            $statusCode,
            $headers
        );
    }

    // CSRFトークン自動出力
    ini_set('url_rewriter.tags', 'form=');
    output_add_rewrite_var(
        $app->escape($app->token->name()),
        $app->escape($app->token->value())
    );

    $response->send();
});

以前、生のセッション変数と独自のトークン処理クラスを使った output_add_rewrite_var()でCSRF対策してみる という記事を書きましたが、こちらはSymfony Sessionを使って似たようなことをやってます。

  • $app->token->name() secret_key設定値を元に生成したトークン名を返す
  • $app->token->value() トークン値(セッションID)を返す
  • $app->token->validate($value) 渡された値とトークン値の検証を行う
  • $app->csrfVerify($method) リクエスト変数から取得した値でトークン値の検証を行う

さらに $app->run() でレスポンス送信前に url_rewriter.tags と output_add_rewrite_var()関数を使ってフォームにCSRFトークン送信用タグを自動出力するように設定しておくことで、アプリケーションオブジェクト利用側ではフォームからのPOST時に $app->csrfVerify('P') とするだけでトークンの検証が実施できるという流れです。

超手抜き実装ですが、CSRF対策としてのトークンにセッションIDを使うのは徳丸本でも推奨されています。

いわゆるワンタイムトークンのような二重送信防止策としては使えませんが、どうせリダイレクトしてしまうので、そちらも問題ないはずです。

(output_add_rewrite_var()とurl_rewriter.tagsでCSRF対策する場合の注意点については、長くなるので記事の最後を見てください)

CSRFトークンの検証とフラッシュメッセージをスクリプトから利用する

アプリケーションオブジェクトへの実装を踏まえて、ページ側のスクリプトはこうなりました。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * 投稿フォーム + エラー/例外テスト + CSRFトークン + フラッシュメッセージのテスト
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

$app->on('GET|POST', function($app, $method) {

    $errors = array();

    $form = array(
        'name'              => $app->findVar('P', 'name'),
        'comment'           => $app->findVar('P', 'comment'),
        'enable_debug'      => $app->findVar('P', 'enable_debug'),
        'move_log_dir'      => $app->findVar('P', 'move_log_dir'),
        'change_secret_key' => $app->findVar('P', 'change_secret_key'),
        'validate_token'    => $app->findVar('P', 'validate_token'),
    );

    // 設定を動的に切り替える
    $app->config->debug = (isset($form['enable_debug']));

    if (isset($form['move_log_dir'])) {
        $app->config->log_dir = $app->config->web_root;
    }

    if ($method === 'POST') {

        // 秘密のキーを変更
        if (isset($form['change_secret_key'])) {
            $app->config->secret_key = bin2hex(openssl_random_pseudo_bytes(32));
        }

        // CSRFトークンの検証
        if (isset($form['validate_token']) && !$app->csrfVerify('P')) {
            $app->abort(403, 'リクエストは無効です。');
        }

        // PHPエラーのテスト
        if (!is_null($app->findVar('P', 'trigger-notice'))) {
            trigger_error('[E_USER_NOTICE]PHPエラーのテストです', E_USER_NOTICE);
        }

        if (!is_null($app->findVar('P', 'trigger-warning'))) {
            trigger_error('[E_USER_WARNING]PHPエラーのテストです', E_USER_WARNING);
        }

        if (!is_null($app->findVar('P', 'trigger-error'))) {
            trigger_error('[E_USER_ERROR]PHPエラーのテストです', E_USER_ERROR);
        }

        // HTTP例外のテスト
        if (!is_null($app->findVar('P', 'throw-http-exception-400'))) {
            $app->abort(400, 'HttpException[400]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-403'))) {
            $app->abort(403,'HttpException[403]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-404'))) {
            $app->abort(404, 'HttpException[404]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-405'))) {
            $app->abort(405, 'HttpException[405]のテストです');
        }

        // 組み込み例外のテスト
        if (!is_null($app->findVar('P', 'throw-runtime-exception'))) {
            throw new RuntimeException('RuntimeExceptionのテストです');
        }

        // フラッシュメッセージのテスト
        if (!is_null($app->findVar('P', 'flash-error'))) {
            $app->flash->addError('フラッシュメッセージ[Error]のテストです');
            return $app->redirect('/');
        }

        if (!is_null($app->findVar('P', 'flash-alert'))) {
            $app->flash->addAlert('フラッシュメッセージ[Alert]のテストです');
            return $app->redirect('/');
        }

        if (!is_null($app->findVar('P', 'flash-success'))) {
            $app->flash->addSuccess('フラッシュメッセージ[Success]のテストです');
            return $app->redirect('/');
        }

        if (!is_null($app->findVar('P', 'flash-info'))) {
            $app->flash->addInfo('フラッシュメッセージ[Info]のテストです');
            return $app->redirect('/');
        }

        // 投稿フォーム処理
        if (strlen($form['name']) === 0) {
            $errors['name'] = '名前を入力してください。';
        } elseif (mb_strlen($form['name']) > 20) {
            $errors['name'] = '名前は20文字以内で入力してください。';
        }

        if (strlen($form['comment']) === 0) {
            $errors['comment'] = 'コメントを入力してください。';
        } elseif (mb_strlen($form['comment']) > 50) {
            $errors['comment'] = 'コメントは50文字以内で入力してください。';
        }

        if (empty($errors)) {
            $app->flash->addSuccess('投稿を受け付けました');
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'title'  => '投稿フォーム',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

機能テストのため無駄に長いのが更に伸びてますが、CSRFトークン検証の有効/無効を切替えたり、フラッシュメッセージのテスト用の項目が増えています。

CSRFトークンの検証を確認するため、トークン名の生成に使われる秘密のキーをフォームから変更できるようにしています。

(CSRFトークン検証エラーの際はステータス403でエラー画面を返していますが、一般的にはどうなんでしょうね…)

投稿フォームのテンプレートです。

www/index.html

<!DOCTYPE html>
<html lang="ja">

<head metal:use-macro="layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="bootstrap/css/bootstrap-responsive.min.css" />
<link rel="stylesheet" href="font-awesome/css/font-awesome.min.css" />
<title>投稿フォーム</title>
</head>

<body metal:use-macro="layout.html/body">

<div class="container-fluid">

  <header class="header">
    <h1>投稿フォーム@example.com</h1>
  </header>

  <div class="content" metal:fill-slot="content">

    <div class="alert alert-error" tal:condition="php:count(errors) > 0">
      <button class="close" data-dismiss="alert">×</button>
      <span class="icon-warning-sign"></span><strong>入力値にエラーがあります</strong>
      <ul>
        <li tal:repeat="error errors" tal:content="error">名前を入力してください。</li>
      </ul>
    </div>

    <form class="form-horizontal" method="post" tal:attributes="action server/REQUEST_URI">
      <fieldset>
        <legend><span class="icon-envelope"></span>投稿フォーム</legend>
        <dl class="control-group" tal:attributes="class php:isset(errors['name']) ? 'control-group error' : 'control-group'">
          <dt class="control-label">名前</dt>
          <dd class="controls">
            <input type="text" name="name" class="input-xlarge" tal:attributes="value form/name" />
          </dd>
        </dl>
        <dl class="control-group" tal:attributes="class php:isset(errors['comment']) ? 'control-group error' : 'control-group'">
          <dt class="control-label">コメント</dt>
          <dd class="controls">
            <textarea name="comment" rows="5" class="input-xlarge" tal:content="form/comment">コメント内容....</textarea>
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="送信" class="btn btn-primary btn-large" />
        </div>
      </fieldset>

      <fieldset>
        <legend><span class="icon-wrench"></span>設定値/エラーのテスト</legend>
        <dl class="control-group">
          <dt class="control-label">デバッグ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="enable_debug" value="1" tal:attributes="checked exists:form/enable_debug" />
              有効にする
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">ログディレクトリ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="move_log_dir" value="1" tal:attributes="checked exists:form/move_log_dir" />
              ドキュメントルートに変更する
            </label>
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="PHPエラー(Notice)" class="btn btn-primary btn-danger" name="trigger-notice" />
          <input type="submit" value="PHPエラー(Warning)" class="btn btn-primary btn-danger" name="trigger-warning" />
          <input type="submit" value="PHPエラー(Error)" class="btn btn-primary btn-danger" name="trigger-error" />
          <input type="submit" value="HTTP例外(400)" class="btn btn-primary btn-danger" name="throw-http-exception-400" />
          <input type="submit" value="HTTP例外(403)" class="btn btn-primary btn-danger" name="throw-http-exception-403" />
          <input type="submit" value="HTTP例外(404)" class="btn btn-primary btn-danger" name="throw-http-exception-404" />
          <input type="submit" value="HTTP例外(405)" class="btn btn-primary btn-danger" name="throw-http-exception-405" />
          <input type="submit" value="RuntimeException" class="btn btn-primary btn-danger" name="throw-runtime-exception" />
        </div>
      </fieldset>

      <fieldset>
        <legend><span class="icon-repeat"></span>セッション関連のテスト</legend>
        <dl class="control-group">
          <dt class="control-label">CSRFトークン</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="validate_token" value="1" tal:attributes="checked exists:form/validate_token" />
              検証を有効にする
            </label>
            <label class="checkbox">
              <input type="checkbox" name="change_secret_key" value="1" tal:attributes="checked exists:form/change_secret_key" />
              秘密のキーを変更する
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">フラッシュメッセージ</dt>
          <dd class="controls">
          <input type="submit" value="Error" class="btn btn-primary btn-danger" name="flash-error" />
          <input type="submit" value="Alert" class="btn btn-primary btn-warning" name="flash-alert" />
          <input type="submit" value="Success" class="btn btn-primary btn-success" name="flash-success" />
          <input type="submit" value="Info" class="btn btn-primary btn-info" name="flash-info" />
          </dd>
        </dl>
      </fieldset>

    </form>

  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

今回追加したセッション関連機能のテスト用フォームが追加されてます。

なお、CSRFトークンの出力は最後に行われるのと、フォームにエラーがない場合はリダイレクトしているため、検証の確認には「秘密のキーを変更する」と「検証を有効にする」を一緒にチェックして送信するか、「秘密のキーを変更する」のみチェックを入れて一度入力値エラーを起こしてから「検証を有効にする」をチェックして送信する必要があります。

前述の通り、フラッシュメッセージの取得と表示は共通テンプレートで行います。

変数のスコープ的にも、今後ページを増やすことを考えても、その方が良いでしょう。

共通レイアウトテンプレート

www/layout.html

<!DOCTYPE html>
<html lang="ja">

<head metal:define-macro="head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script src="//netdna.bootstrapcdn.com/twitter-bootstrap/latest/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/twitter-bootstrap/latest/css/bootstrap-combined.no-icons.min.css" />
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css" />
<title tal:content="title|default">ページタイトル</title>
</head>

<body metal:define-macro="body">

<div class="navbar">
  <div class="navbar-inner">
    <div class="container">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">Menu<span class="caret"></span></a>
      <div class="nav-collapse collapse">
        <ul class="nav pull-left">
          <li><a class="brand" href="/">俺のフレームワーク</a></li>
        </ul>
      </div>
      <div class="nav-collapse collapse">
        <ul class="nav pull-left">
          <li><a href="/"><span class="icon-envelope"></span>投稿フォーム</a></li>
          <li><a href="/index.html"><span class="icon-envelope"></span>投稿フォーム(テンプレート)</a></li>
          <li><a href="/error.html"><span class="icon-warning-sign"></span>エラーページ(テンプレート)</a></li>
          <li><a href="/layout.html"><span class="icon-file"></span>レイアウト(テンプレート)</a></li>
        </ul>
      </div>
    </div>
  </div>
</div>

<div class="container-fluid">

  <header class="header">
    <h1><span tal:replace="title|default">ページタイトル</span>@<span tal:replace="server/HTTP_HOST">example.com</span></h1>
  </header>

  <div class="alert alert-error" tal:condition="app/flash/hasError">
    <button class="close" data-dismiss="alert">×</button>
    <ul>
      <li tal:repeat="message app/flash/getError" tal:content="message">Error Flashメッセージ</li>
    </ul>
  </div>

  <div class="alert" tal:condition="app/flash/hasAlert">
    <button class="close" data-dismiss="alert">×</button>
    <ul>
      <li tal:repeat="message app/flash/getAlert" tal:content="message">Alert Flashメッセージ</li>
    </ul>
  </div>

  <div class="alert alert-success" tal:condition="app/flash/hasSuccess">
    <button class="close" data-dismiss="alert">×</button>
    <ul>
      <li tal:repeat="message app/flash/getSuccess" tal:content="message">Success Flashメッセージ</li>
    </ul>
  </div>

  <div class="alert alert-info" tal:condition="app/flash/hasInfo">
    <button class="close" data-dismiss="alert">×</button>
    <ul>
      <li tal:repeat="message app/flash/getInfo" tal:content="message">Info Flashメッセージ</li>
    </ul>
  </div>

  <div class="content" metal:define-slot="content">
    ページコンテンツ
  </div>

  <div class="row-fluid debug" tal:condition="app/config/debug">
    <table class="table table-striped table-condensed">
      <caption><h3>$_SERVER環境変数</h3></caption>
      <tbody>
        <tr tal:repeat="var server">
          <th tal:content="repeat/var/key">環境変数名</th>
          <td tal:content="var">環境変数値</td>
        </tr>
      </tbody>
    </table>
  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

$app->flash にくどくどと実装したおかげで、結構すっきり書けました。

$app->renderer でのレンダラ生成時にアプリケーションオブジェクトをアサインしているため、このような記述で参照可能になったのですが…。

アプリケーションオブジェクトはいわばアプリケーションスコープのグローバル変数みたいな大きい存在なので、テンプレート側に $app->escape() などと書く必要がなくなった現状、これに自由にアクセスできるのは良くない気もします。

とりあえず、利用するのは共通レイアウト側だけに留めておいた方が正解かも。

output_add_rewrite_var()とurl_rewriter.tagsでCSRF対策する場合の注意点

output_add_rewrite_var()で要注意なのは、output_add_rewrite_var()でCSRF対策してみる にも書きましたが、url_rewriter.tagsの設定に従って出力される結果は名前、値ともにHTMLエスケープされないことと、無条件で値がパーセントエンコードRFC 3986準拠のrawurlencode()ではなくurlencode()で…)されてしまうことです。

HTMLエスケープされない件は、名前の方は「" /><script>alert("Hello!");</script><input name="PHPSESSID」などといったものを設定しない限り大丈夫だと思いますが、厄介なのは値がパーセントエンコードされてしまうことです。

セッションIDは session.hash_bits_per_character の設定値によって 4の場合は「0-9,a-f」、5の場合は「0-9,a-v」と、パーセントエンコード/HTMLエスケープとも不要な文字で問題ないのですが、6に設定している場合は「0-9, a-z, A-Z, "-", ","」が利用される(PHPマニュアル)ため、","がパーセントエンコードされてしまうのです。

サンプルコードでNativeSessionStorageのコンストラクタに hash_bits_per_character = 5 を指定しているのは、動作確認しているのがWindows環境ということもありますが、この問題を回避するためでもあります。

また、url_rewriter.tags は session.use_trans_sid の動作にも影響しますので、もう機会は少ないと思いますがいわゆる「透過的なセッションID」の機能を利用する場合も要注意です。

(レスポンス出力後にoutput_reset_rewrite_vars()とかurl_rewriter.tagsの書き戻しをやっておいた方が行儀は良いんですが、output_add_rewrite_var()はPHPの出力バッファとして動作するため、めんどくさくなってやめました)

後は、トークンの値にセッションIDをそのまま利用しているので、セッションIDを変更する際(Symfony HttpFoundationのSessionクラスでは migrate()メソッドが利用できるようです)はタイミングに気をつける必要がありますね。

(昔のいわゆる「ケータイサイト」で trans_sid を使ってて、ブラウザキャッシュから戻るとログイン状態が切れてしまうというアホな失敗をやらかしたのを思い出しました…)

とまあ、このように結構罠が多い上に、Ajaxで送信する場合はやっぱり対応できないので、いにしえのスクリプトに手間をかけずCSRF対策しなきゃいけないようなケースを除き、この方法で手を抜くのは止めておいた方が良さそうです。というオチでした…。

さて、次はそろそろ、これまでずっと無視してきた「URLルーティング」の問題に取り組もうと思います。