PSR7Session で JWT によるストレージ不要の HTTP セッションを試す

Doctrine 開発チームの Ocramius さんが手がける PSR7Session を試したことの記録やドキュメントを翻訳しました。PSR7Session は PSR-7 (HTTP メッセージインターフェイスの標準仕様) に対応するミドルウェアです。

PSR-7 のミドルウェアのあり方
------------------------

[PHP、Node.js、Go のミドルウェアに関する調査](http://qiita.com/masakielastic/items/c9825434fda404231cbc) の記事をご参照ください。

Zend Expressive のセットアップ
----------------------------

サンプルアプリのために Zend Expressive を使いました。Zend Expressive の開発者である mwop (Matthew Weier O'Phinney) さんは PSR-7 の策定に関わっています。パッケージの構成は次のとおりです。

```json:composer.json
{
    "require": {
        "ocramius/psr7-session": "^1.0",
        "zendframework/zend-expressive": "^0.5.3",
        "zendframework/zend-servicemanager": "^2.6",
        "zendframework/zend-expressive-fastroute": "^0.3.0"
    }
}
```

パッケージをダウンロードしましょう。

```bash
composer update
```

`composer create-project` でプロジェクトをセットアップすることもできます。

```bash
composer create-project zendframework/zend-expressive-skeleton:1.0.0rc5@rc expressive
```

`public/index.php` を編集してルートおよびコントローラーを追加します。

```php
// Delegate static file requests back to the PHP built-in webserver
if (php_sapi_name() === 'cli-server'
    && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))
) {
    return false;
}

chdir(dirname(__DIR__));
require 'vendor/autoload.php';

/** @var \Interop\Container\ContainerInterface $container */
$container = require 'config/container.php';

/** @var \Zend\Expressive\Application $app */
$app = $container->get('Zend\Expressive\Application');
$app->get('/', function ($request, $response, $next) {
    $response->write('Hello, world!');
    return $response;
});
$app->run();
```


アクセスカウンター
---------------

セッション学習の定番題材としてアクセスカウンターをつくってみましょう。次のコードを `index.php` として保存します。

```php
use PSR7Session\Http\SessionMiddleware;
use Dflydev\FigCookies\SetCookie;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

require_once __DIR__ . '/vendor/autoload.php';

$sessionMiddleware = new SessionMiddleware(
    new Sha256(),
    'a very complex symmetric key',
    'a very complex symmetric key',
    SetCookie::create('an-example-cookie-name')
        ->withSecure(false)
        ->withHttpOnly(true),
    new Parser(),
    1200 // 20 分
);

$app = \Zend\Expressive\AppFactory::create();
$app->pipe($sessionMiddleware);

$app->get('/', function (ServerRequestInterface $request, ResponseInterface $response) : ResponseInterface {
    $session = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);
    $session->set('counter', $session->get('counter', 0) + 1);

    $response
        ->getBody()
        ->write('カウンター: ' . $session->get('counter'));

    return $response;
});

$app->run();
```

HTTPS の環境にあるなら、ファクトリメソッドを使うことができます。

```php
$sessionMiddleware = PSR7Session\Http\SessionMiddleware::fromSymmetricKeyDefaults(
    'a symmetric key',
    1200
);
```

アプリにアクセスする
-----------------

HTTP サーバー経由でブラウザーでアクセスしてみましょう。PHP のビルトインサーバーを起動させるのであれば、次のコマンドを実行します。

```php
php -S localhost:3000 index.php
```

ブラウザーをリロードするたびにカウンターの値が増えることを確認しましょう。

HTTP 通信を眺める
---------------

コマンドツールの httpie で2回 HTTP リクエストを送信して、HTTP メッセージを見てみましょう。

```bash
> http -v --session test localhost:3000

GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:3000
User-Agent: HTTPie/0.9.2



HTTP/1.1 200 OK
Connection: close
Content-Length: 18
Content-type: text/html; charset=UTF-8
Host: localhost:3000
Set-Cookie: an-example-cookie-name=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0NDkyMDg3NzQsImV4cCI6MTQ0OTIwOTk3NCwic2Vzc2lvbi1kYXRhIjp7ImNvdW50ZXIiOjF9fQ.Yhw0PcJq7R0IAP20hYhZA7eFDXWoj6lZVBakRC_Ql1E; Expires=Fri, 04 Dec 2015 06:19:34 GMT; HttpOnly
X-Powered-By: PHP/7.0.0

カウンター: 1
```

```bash
> http -v --session test localhost:3000

GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Cookie: an-example-cookie-name=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0NDkyMDg3NzQsImV4cCI6MTQ0OTIwOTk3NCwic2Vzc2lvbi1kYXRhIjp7ImNvdW50ZXIiOjF9fQ.Yhw0PcJq7R0IAP20hYhZA7eFDXWoj6lZVBakRC_Ql1E
Host: localhost:3000
User-Agent: HTTPie/0.9.2


HTTP/1.1 200 OK
Connection: close
Content-Length: 18
Content-type: text/html; charset=UTF-8
Host: localhost:3000
Set-Cookie: an-example-cookie-name=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0NDkyMDg4MDgsImV4cCI6MTQ0OTIxMDAwOCwic2Vzc2lvbi1kYXRhIjp7ImNvdW50ZXIiOjJ9fQ.FWO_q5rXYhmjN7Nws-UhaDONiGicpRbZvHcDLNnSxgI; Expires=Fri, 04 Dec 2015 06:20:08 GMT; HttpOnly
X-Powered-By: PHP/7.0.0

カウンター: 2
```

Cookie のトークンを検証する
------------------------

HTTP ヘッダーに含まれる Cookie のトークンが有効であるか検証したり、カウンターの値を取り出してみましょう。

```php
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Signer\Hmac\Sha256;

require_once __DIR__ . '/vendor/autoload.php';

$token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0NDkxOTg0NDAsImV4cCI6MTQ0OTE5OTY0MCwic2Vzc2lvbi1kYXRhIjp7ImNvdW50ZXIiOjEwfX0.VfoyQ1C7v58Tcm53IF0-T9SsdePIRitNMFAMirSOUqM';
$token = (new Parser())->parse((string) $token);

$signer = new Sha256();
$verificationKey = 'a very complex symmetric key';

$valid = $token->verify($signer, $verificationKey);
$ret = (object) $token->getClaim('session-data', new \stdClass());

var_dump(
    $valid,
    $ret->counter
);
```

ext/session の問題
------------------

[README](https://github.com/Ocramius/PSR7Session) で次のことが挙げられます。PSR7Session を開発する動機でもあります。

 * スーパーグローバルの $_SESSION に依存する。
 * ストレージにセッションを「コミット」するためにシャットダウンハンドラーに依存する。
 * ストレージがあるために大勢のアクティブユーザーの大きな制限を抱える。
 * ストレージがあるためにたくさんの I/O が起きる。
 * 異なるプロセスにまたがってデータをシリアライズしなければならない (PHP は `$_SESSION` のシリアライズとシリアライズの解除を実行し、セキュリティに関する意味合いがある)
 * セットアップのために水平スケーリングする集中管理型のストレージを使わなければならない。
 * ストレージが集中管理されていない場合、(「かしこい」ロードバランサーで) スティッキーセッションを使わなければならない。
 * 複数のディスパッチサイクルのために設計されていない。

前提
---

 * セッションはかなり小さく、かぎられた識別子と CSRF トークンを含むのみである。小さいとは400バイトよりも小さいことを意味する。
 * セッションのデータは `JsonSerializable` もしくはそれに相当するものである。
 * セッションのデータはクライアントが自由に読むことができる。

どのように動くのか
---------------

セッションのデータは JWT としてセッション Cookie 内部に直接保存されます。

この方法は新しいものはなく、HTTP/REST/OAuth API の Bearer トークンと一緒に広く使われます。

セッションデータが改変されず、クライアントが情報を信頼し、サーバーとクライアントのあいだで有効期限をお互いに同意することを保証するために、JWT が情報の送信に使われます。

ユーザーエージェントがセッションを操作できないように JWT は常に証明つきです。トークンの証明と検証のために、対称および非対称鍵の両方がサポートされます。

利点
----

  * ストレージが不要である。
  * スティッキーセッションが不要である (秘密もしくは公開鍵のコピーを保有するサーバーはセッションを生成もしくはセッションを消費できる)。
  * クライアントにクリアテキストの情報を送信可能で、情報をサーバーと共有することを可能にする (標準的な例は任意のセッションで「ユーザー名」もしくは「ユーザー id」を共有する)
  * クライアントに暗号化された情報を送信可能で、サーバー限定の情報の消費を可能にする。
  * PHP のシリアライゼーション RCE 攻撃の影響を受けない。
  * PHP のプロセススコープに限定されない: プロセスごとに複数のセッションを提供できる。
  * グローバルな状態に頼らなくてすむ。
  * 複数のサーバーのセットアップにおいて、公開鍵への権限のみをもつサーバーに読み込み限定の権限を許可する一方で、書き込みは秘密鍵への権限をもつサーバーにかぎられる。
  * 複数のディスパッチサイクルで繰り返し使うことができる。

既知の制約事項
------------

[制約事項](https://github.com/Ocramius/PSR7Session/blob/master/docs/limitations.md)として次のことが挙げられています。

 * セッションに秘密情報を保存できない。
 * セッションを無効にできない。
 * ネットワークトラフィックが増加する。
 * セッションに書き込む HTTP リクエストが一度にたくさんあるときに競合状態が起きる。
 * セッションに保存するデータ量の制限がある。