30.3. 高度な使用法

基本的な使用法の例で Zend Framework のセッションを完全に使用することができますが、 よりよい方法もあります。 Zend_Auth のサンプル を見てみましょう。これは、デフォルトで Zend_Session_Namespace を使用することにより、 認証トークンを持続的に保持している例です。 この例は、Zend_Session_Namespace および Zend_Auth を手早く簡単に統合するためのひとつの方法を示すものです。

30.3.1. セッションの開始

すべてのリクエストで Zend Framework のセッションを使用してセッション管理したい場合は、 起動ファイルでセッションを開始します。

例 30.6. グローバルセッションの開始

<?php
...
require_once 'Zend/Session.php';
Zend_Session::start();
...
?>

起動ファイルでセッションを開始する際には、 ヘッダがブラウザに送信される前に確実にセッションが始まるようにします。 そうしないと例外が発生してしまい、おそらくユーザが見るページは崩れてしまうでしょう。 さまざまな高度な機能を使用するには、まず Zend_Session::start() が必要です (高度な機能の詳細については後で説明します)。

Zend_Session を使用してセッションを開始する方法は四通りありますが、 そのうち二つは間違った方法です。

  • 1. 間違い: PHP の session.auto_start (http://www.php.net/manual/ja/ref.session.php#ini.session.auto-start) を、php.ini や .htaccess で設定してはいけません。 もし mod_php (やそれと同等のもの) を使用しており、 php.ini でこの設定が有効になっている、かつそれを無効にすることができない という場合は、.htaccess ファイル (通常は HTML のドキュメントルートにあります) に php_value session.auto_start 0 を追加します。

  • 2. 間違い: PHP の session_start() 関数を直接使用してはいけません。 session_start() を直接使用した後で Zend_Session_Namespace を使用した場合は、 Zend_Session::start() が例外 ("session has already been started") をスローします。Zend_Session_Namespace を使用するか 明示的に Zend_Session::start() で開始した後で session_start() をコールすると、E_NOTICE が発生し、そのコールは無視されます。

  • 3. 正解: Zend_Session::start() を使用します。 すべてのリクエストでセッションを使用したい場合は、 この関数コールを ZF の起動コードの最初のほうで無条件に記述します。 セッションにはある程度のオーバーヘッドがあります。 セッションを使用したいリクエストとそうでないリクエストがある場合は、

    • 起動コード内で、無条件にオプション strict を true にします ( Zend_Session::setOptions() を参照ください )。

    • セッションを必要とするリクエスト内で、 最初に new Zend_Session_Namespace() をコールする前に、Zend_Session::start() をコールします。

    • 通常どおり、必要に応じて new Zend_Session_Namespace() を使用します。事前に Zend_Session::start() がコールされていることを確認しておきましょう。

    strict オプションにより、new Zend_Session_Namespace() が自動的に Zend_Session::start() でセッションを開始することがなくなります。 したがって、このオプションを使用すると、ZF アプリケーションの開発者が 特定のリクエストにはセッションを使用しないという設計をおこなうことができます。 このオプションを使用すると、明示的に Zend_Session::start() をコールする前に Zend_Session_Namespace のインスタンスを作成しようとしたときに例外がスローされます。 ZF のコアライブラリのコードではこのオプションを使用しないでください。 このような設計上の決断をくだすのは、アプリケーションの開発者だからです。 同様に、"ライブラリ" の開発者も、Zend_Session::setOptions() の使用がユーザにどれだけの影響を与えるかを注意するようにしましょう。 これらのオプションは (もととなる ext/session のオプションと同様)、 全体に副作用を及ぼすからです。

  • 4. 正解: 必要に応じて new Zend_Session_Namespace() を使用します。 セッションは、Zend_Session の内部で自動的に開始されます。 これはもっともシンプルな使用法で、たいていの場合にうまく動作します。 しかし、デフォルトであるクッキーベースのセッション (強く推奨します) を使用している場合には、PHP がクライアントに何らかの出力 (HTTP ヘッダ など) をする 前に、確実に 最初の new Zend_Session_Namespace() をコールしなければなりません。 詳細は 項30.4.3.1. 「Headers already sent」 を参照ください。

30.3.2. セッション名前空間のロック

セッション名前空間をロックし、 それ以降その名前空間のデータに手を加えられないようにすることができます。 特定の名前空間を読み取り専用にするには Zend_Session_Namespace の lock() を、そして 読み取り専用の名前空間を読み書きできるようにするには unLock() を使用します。isLocked() を使用すると、 その名前空間がロックされているかどうかを調べることができます。 このロックは一時的なものであり、そのリクエスト内でのみ有効となります。 名前空間をロックしても、その名前空間に保存されているオブジェクトの セッターメソッドには何の影響も及ぼしません。 しかし、名前空間自体のセッターメソッドは使用できず、 名前空間に直接格納されたオブジェクトの削除や置換ができなくなります。同様に、 Zend_Session_Namespace の名前空間をロックしたとしても、 同じデータをさすシンボルテーブルの使用をとめることはできません (PHP のリファレンスについての説明も参照ください)。

例 30.7. セッション名前空間のロック

<?php
    // このように仮定します
    $userProfileNamespace = new Zend_Session_Namespace('userProfileNamespace');

    // このセッションに読み取り専用ロックをかけます
    $userProfileNamespace->lock();

    // 読み取り専用ロックを解除します
    if ($userProfileNamespace->isLocked()) {
        $userProfileNamespace->unLock();
    }
?>

ウェブの世界で、MVC のモデルをどのように管理するかについては、 さまざまな考え方があります。その中のひとつに、 ビューで使用するプレゼンテーションモデルを作成するというものもあります。 ドメインモデルの中にある既存のデータで十分ということもあるでしょう。 ビューの中でこれらのデータに処理ロジックが書きくわえられてしまうことのないように、 セッション名前空間をロックしてからその「プレゼンテーション」 モデルをビューに渡すようにしましょう。

例 30.8. ビューにおけるセッションのロック

<?php
class FooModule_View extends Zend_View
{
    public function show($name)
    {
        if (!isset($this->mySessionNamespace)) {
            $this->mySessionNamespace = Zend::registry('FooModule');
        }

        if ($this->mySessionNamespace->isLocked()) {
            return parent::render($name);
        }

        $this->mySessionNamespace->lock();
        $return = parent::render($name);
        $this->mySessionNamespace->unLock();

        return $return;
    }
}
?>

30.3.3. 名前空間の有効期限

名前空間およびその中の個々のキーについて、その寿命を制限することができます。 これは、たとえばリクエスト間で一時的な情報を渡す際に使用します。 これにより、認証内容などの機密情報へアクセスできないようにし、 セキュリティリスクを下げます。有効期限の設定は経過秒数によって決めることもできますし、 "ホップ" 数によって決めることもできます。ホップ数とは、 一連のリクエストにおいて、最低でも一度の $space = new Zend_Session_Namespace('myspace'); で名前空間をアクティブにした回数を表します。

例 30.9. 有効期限切れの例

<?php
$s = new Zend_Session_Namespace('expireAll');
$s->a = 'apple';
$s->p = 'pear';
$s->o = 'orange';

$s->setExpirationSeconds(5, 'a'); // キー "a" だけは 5 秒で有効期限切れとなります

// 名前空間全体は、5 "ホップ" で有効期限切れとなります
$s->setExpirationHops(5);

$s->setExpirationSeconds(60);                  
// "expireAll" 名前空間は、60 秒が経過するか
// 5 ホップに達するかのどちらかが発生した時点で
// "有効期限切れ" となります
?>

現在のリクエストで期限切れになったデータを扱うにあたり、 データを取得する際には注意が必要です。 データは参照で返されますが、それを変更したとしても 期限切れのデータを現在のリクエストから持ち越すことはできません。 有効期限を "リセット" するには、取得したデータをいったん一時変数に格納し、 名前空間上の内容を削除し、あらためて適切なキーで再設定します。

30.3.4. コントローラでのセッションのカプセル化

名前空間を使用すると、コントローラによるセッションへのアクセスの際に 変数の汚染を防ぐこともできます。 たとえば、'Zend_Auth' コントローラでは、そのセッション状態データを 他のコントローラとは別に管理することになるでしょう。

例 30.10. コントローラでの名前空間つきセッションによる有効期限の管理

<?php
require_once 'Zend/Session.php';
// 質問を表示するコントローラ
$testSpace = new Zend_Session_Namespace('testSpace');
$testSpace->setExpirationSeconds(300, "accept_answer"); // $test_session->setExpirationSeconds(300, "accept_answer"); // この変数にだけ有効期限を設定します
$testSpace->accept_answer = true;


--

// 回答を処理するコントローラ
$testSpace = new Zend_Session_Namespace('testSpace');

if ($testSpace->accept_answer === true) {
    // 時間内
}
else {
    // 時間切れ
}
?>

30.3.5. 名前空間内での Zend_Session_Namespace のインスタンスをひとつに制限する

ここで説明する機能を使用するよりも、セッションのロック (上を参照ください) を使うことを推奨します。ここで説明する機能は、 各名前空間へのアクセスが必要なすべての関数およびオブジェクトに Zend_Session_Namespace のインスタンスを渡さなければならず、 開発者への負担が大きくなります。

特定の名前空間用に Zend_Session_Namespace の最初のインスタンスを作成する際に、 その名前空間ではこれ以上別の Zend_Session_Namespace を作成しないよう指示することができます。 こうすると、その後同じ名前空間で Zend_Session_Namespace のインスタンスを作成しようとした際にエラーが発生します。 これはオプションの設定であり、デフォルトではありません。ひとつの名前空間に対しては ひとつのインスタンスだけを使用したいという人のために残しています。 これは、特定のセッション名前空間を コンポーネントが不意に書き換えてしまう危険性を減らします。 セッションへのアクセスが容易ではなくなるからです。 しかし、名前空間に対してひとつのインスタンスに限定してしまうと、 コードの量が増え、より複雑になってしまいます。なぜなら、便利な $aNamespace = new Zend_Session_Namespace('aNamespace'); が最初の一度しか使えなくなるからです。それ以降は、以下の例のようになります。

例 30.11. 単一のインスタンスへの制限

<?php
    require_once 'Zend/Session.php';
    $authSpaceAccessor1 = new Zend_Session_Namespace('Zend_Auth');
    $authSpaceAccessor2 = new Zend_Session_Namespace('Zend_Auth', Zend_Session_Namespace::SINGLE_INSTANCE);
    $authSpaceAccessor1->foo = 'bar';
    assert($authSpaceAccessor2->foo, 'bar'); // 通過します
    doSomething($options, $authSpaceAccessor2); // 必要に応じてアクセサを渡します
    .
    .
    .
    $aNamespaceObject = new Zend_Session_Namespace('Zend_Auth'); // これはエラーとなります
?>

上の例で Zend_Session_Namespace のコンストラクタの第二パラメータで指定しているのは、 今後 'Zend_Auth' 名前空間で新たに Zend_Session を作成することができないということです。 作成しようとすると、例外がスローされます。 上のコードを実行した後は new Zend_Session_Namespace('Zend_Auth') ができなくなります。そのため、 同一リクエスト内でその名前空間のセッションを使用するには、 最初に作成したインスタンス (上の例では $authSpaceAccessor2) をどこかに保存しておく必要があります。 たとえば静的変数にこのインスタンスを格納したり、 この名前空間のセッションを必要とするメソッドに直接渡したりします。 セッションのロック (上を参照ください) のほうが、 名前空間へのアクセスを制限する方法としてはより便利で簡単です。

30.3.6. 名前空間での配列の使用

名前空間内の配列を変更することはできません。 最も簡単な対応法は、必要な値をすべて設定してから配列を保存することです。 ZF-800 で、マジックメソッドと配列を使用している多くの PHP アプリケーションに影響する既知の問題を説明しています。

例 30.12. 配列に関する既知の問題

<?php
    $sessionNamespace = new Zend_Session_Namespace('Foo');
    $sessionNamespace->array = array();
    $sessionNamespace->array['testKey'] = 1; // PHP 5.2.1 より前のバージョンでは動作しません
?>

セッション名前空間に代入した後で配列を変更する必要が出てきた場合は、 まずいったん配列を取得し、そして変更した後でその配列をセッション名前空間に書き戻します。

例 30.13. 回避策: 取得して変更し、そして保存する

<?php
    $sessionNamespace = new Zend_Session_Namespace('Foo');
    $sessionNamespace->array = array('tree' => 'apple');
    $tmp = $sessionNamespace->array;
    $tmp['fruit'] = 'peach';
    $sessionNamespace->array = $tmp;
?>

あるいは、目的の配列への参照を保持する配列をセッションに保存し、 間接的にアクセスします。

例 30.14. 回避策: 参照を含む配列を保存する

<?php
    $myNamespace = new Zend_Session_Namespace('mySpace');

    // バグのあるバージョンの PHP でも動作します
    $a = array(1,2,3);
    $myNamespace->someArray = array( & $a ) ;
    $a['foo'] = 'bar';
?>

30.3.7. セッションと認証の共用

Zend_Auth 用の認証アダプタが返す承認結果がオブジェクトであって (非推奨です) 配列ではなかった場合は、セッションを開始する前に 承認クラスを require しておく必要があります。 そのかわりに、セッション名前空間の既知のキーをもとに認証アダプタ内で計算した値を 保存しておくことを推奨します。 たとえば、Zend_Auth のデフォルトの動作は、 これを名前空間 'Zend_Auth' のキー 'storage' に配置します。

Zend_Auth に対して認証トークンをセッション間で持続させないよう指示し、 手動で承認 ID をセッション名前空間に格納することもできます。 そうすれば、セッション名前空間内のよく知られた場所を使用できます。 アプリケーションによっては、使用する権限情報や承認情報を 特定の場所に保存しなければならないこともあるでしょう。 多くのアプリケーションは、認証時 つまり Zend_Auth の authenticate() メソッド実行時の ID (たとえばユーザ名) を特定の ID (一意に割り当てた整数値など) に関連付けています。

例 30.15. 例: 単純化した承認 ID へのアクセス

<?php
    // 認証前のリクエスト
    require_once 'Zend/Auth/Adapter/Digest.php';
    $adapter = new Zend_Auth_Adapter_Digest($filename, $realm, $username, $password);
    $result = $adapter->authenticate();
    require_once 'Zend/Session/Namespace.php';
    $namespace = new Zend_Session_Namespace('Zend_Auth');
    if ($result->isValid()) {
        $namespace->authorizationId = $result->getIdentity();
        $namespace->date = time();
    } else {
        $namespace->attempts++;
    }

    // それ以降のリクエスト
    require_once 'Zend/Session.php';
    Zend_Session::start();
    $namespace = new Zend_Session_Namespace('Zend_Auth');

    echo "Valid: ", (empty($namespace->authorizationId) ? 'No' : 'Yes'), "\n"';
    echo "Authorization / user Id: ", (empty($namespace->authorizationId)
        ? 'none' : print_r($namespace->authorizationId, true)), "\n"';
    echo "Authentication attempts: ", (empty($namespace->attempts)
        ? '0' : $namespace->attempts), "\n"';
    echo "Authenticated on: ",
        (empty($namespace->date) ? 'No' : date(DATE_ATOM, $namespace->date), "\n"';
?>

承認 ID をクライアント側に保存すると、 それをサーバ側で使用する場合に権限昇格の脆弱性を引き起こします。 これを防ぐには、ID をサーバ側で複製し (セッションを利用するなどして)、 クライアントから送られた認証 ID との間でクロスチェックするなどの方法があります。 "認証 (authentication) ID" (たとえばユーザ名) と "承認 (authorization) ID" (たとえばデータベースのユーザテーブルの ID 101 番) をきちんと区別するようにしましょう。

後者については、パフォーマンス上の理由からそれほど珍しくありません。 つまり、サーバに保存しておいたセッション情報を取得することで、 いわゆる「ニワトリが先かタマゴが先か」の問題を解決するような場合です。 「クッキーに承認 ID そのものを保存するのか、 あるいは本物の承認 ID (またはユーザのセッション/ プロファイル情報を保持する何か) の代用となる別のものを保存するのか」 ということがよく議論の対象となります。 システムのセキュリティ担当者の中には、 「DB の主キー」が外部に漏らされることを好まない人たちもいるようです。 彼らは、SQL インジェクション脆弱性が発見された場合の被害を抑えようとしているのです。 だれもが承認 ID に自動インクリメント形式を使用しているわけではありません。

30.3.8. ユニットテストでのセッションの使用

Zend Framework 自体のテストには PHPUnit を使用しています。 多くの開発者は、このテストスイートを拡張して自分のアプリケーションのコードをテストしています。 ユニットテスト中で、セッションの終了後に書き込み関連のメソッドを使用すると "Zend_Session is currently marked as read-only" という例外がスローされます。しかし、Zend_Session を使用するユニットテストには要注意です。 セッションを閉じたり (Zend_Session::writeClose()) 破棄したり (Zend_Session::destroy()) したら、 それ以降は Zend_Session_Namespace へのキーの設定や削除ができなくなります。 これは、ext/session や、PHP の PHP session_destroy() および session_write_close() の仕様によるものです, これらには、ユニットテストの setup/teardown 時に使用できるような、いわゆる "undo" 機能が備わっていないのです。

この問題の回避策は、 tests/Zend/Session/SessionTest.php および SessionTestHelper.php のユニットテストテスト testSetExpirationSeconds() を参照ください。 これは、PHP の exec() によって別プロセスを起動しています。 新しいプロセスが、ブラウザからの二番目以降のリクエストをシミュレートします。 この別プロセスの開始時にはセッションを "初期化" します。 ちょうど、ふつうの PHP スクリプトがウェブリクエストを実行する場合と同じような動作です。 また、呼び出し元のプロセスで $_SESSION[] を変更すると、 子プロセスでそれが反映されます。親側では exec() を使用する前にセッションを閉じています。

例 30.16. PHPUnit による、Zend_Session* を使用したコードのテスト

<?php
        // setExpirationSeconds() をテストします
        require 'tests/Zend/Session/SessionTestHelper.php'; // trunk/ の SessionTest.php も参照ください
        $script = 'SessionTestHelper.php';
        $s = new Zend_Session_Namespace('space');
        $s->a = 'apple';
        $s->o = 'orange';
        $s->setExpirationSeconds(5);

        Zend_Session::regenerateId();
        $id = Zend_Session::getId();
        session_write_close(); // セッションを開放し、これ以降で使用できるようにします
        sleep(4); // 無効となるほどの時間ではありません
        exec($script . "expireAll $id expireAll", $result);
        $result = $this->sortResult($result);
        $expect = ';a === apple;o === orange;p === pear';
        $this->assertTrue($result === $expect,
            "iteration over default Zend_Session namespace failed; expecting result === '$expect', but got '$result'");

        sleep(2); // 無効になります (全部で 6 秒待機していますが、有効期限は 5 秒です)
        exec($script . "expireAll $id expireAll", $result);
        $result = array_pop($result);
        $this->assertTrue($result === '',
            "iteration over default Zend_Session namespace failed; expecting result === '', but got '$result')");
        session_start(); // 人為的にサスペンドしたセッションを復活させます

        // これを別のテストに分割するることもできます。しかし実際のところ、
        // 上のテストの残骸が以下のテストに影響を及ぼすとしたら、それはバグでしょう。
        // バグは、ここで発見しておくべきものです。
        $s = new Zend_Session_Namespace('expireGuava');
        $s->setExpirationSeconds(5, 'g'); // 名前空間内のキーひとつだけを無効にしようとします
        $s->g = 'guava';
        $s->p = 'peach';
        $s->p = 'plum';

        session_write_close(); // セッションを開放し、これ以降で使用できるようにします
        sleep(6); // 無効となるほどの時間ではありません
        exec($script . "expireAll $id expireGuava", $result);
        $result = $this->sortResult($result);
        session_start(); // 人為的にサスペンドしたセッションを復活させます
        $this->assertTrue($result === ';p === plum',
            "iteration over named Zend_Session namespace failed (result=$result)");
?>