この記事は弁護士ドットコム Advent Calendar 2024 の 21 日目の記事です。
はじめに
リーガルブレイン開発室の tsuchiya です。
先日、セッション ID を JWT に内包するという記事を読みました。
上記記事では、 セッション ID を内包した JWT を活用する ことについて紹介しています。 JWT の性質を最大限活用したもので、非常に共感できる内容でした。
本記事では上記記事をうけて、セッション ID を内包した JWT をセッション Cookie として使用する処理を PHP で実装してみたので、その内容について紹介します。
JWT とは
本記事では、JWT の詳細な仕様に関する説明は省略しますが、本記事を理解するうえで重要になる性質について簡単に紹介します。
JSON Web Token(JWT)は、以下のような性質を持った汎用フォーマットです。
- ヘッダーとペイロード、その署名から構成されており、ヘッダーとペイロードの改ざんを検知できる
- ペイロードには、任意のデータを格納できる
- URL セーフな文字列で表現される
RFC 7519 で定義されており、
IANA のメディアタイプレジストリには、application/jwt
として登録されています。
また IANA の JWT Registered Claim Names に、クレーム名が登録されています。
PHP におけるセッションの利用
実装について紹介する前に、まずは PHP におけるセッションの仕組みについて簡単に説明します。 PHP には、言語標準(正確には拡張モジュール)としてセッション機能が提供されており、ライブラリを利用することなくセッションを利用できます。 拡張モジュールはデフォルトで有効になっていますが、コンパイル時に無効化もできます。 詳しくは、PHP のマニュアル をご確認ください。
基本的なセッション操作
PHP 公式ドキュメントに記載されている基本的な使い方を以下に引用します。
<?php session_start(); if (!isset($_SESSION['count'])) { $_SESSION['count'] = 0; } else { $_SESSION['count']++; } ?>
上記のコードは、session_start
関数によってセッションを開始し、$_SESSION
というスーパーグローバル変数を利用してセッション変数を操作しています。
基本的なセッション操作は上記のとおりですが、現代のフレームワークを前提とした PHP アプリケーションにおいては、スーパーグローバル変数を直接操作することはめったにないでしょう。 フレームワークを利用せずともセッション操作を担うライブラリを利用することが一般的です。
SessionHandlerInterface を利用したカスタムセッションハンドラ
言語標準として実装されているセッション機能は、SessionHandlerInterface を実装したクラスを利用してカスタマイズできます。 セッションハンドラをカスタマイズすることで、セッションの保存先を変更したり、セッションの有効期限を変更できます。
以下に、session_set_save_handler のマニュアル に記載されているコード例を引用します。
<?php class MySessionHandler implements SessionHandlerInterface { // ここでインターフェイスを実装します } $handler = new MySessionHandler(); session_set_save_handler($handler, true); session_start(); // $_SESSION への値の設定や格納されている値の取得を進めます
上記のコードは、SessionHandlerInterface
を実装した MySessionHandler
というクラスを、
session_set_save_handler
関数を利用して登録しています。
PHP でセッション ID を内包した JWT を実装する
それでは、実装についてみていきます。 今回利用するコードは、GitHub のリポジトリ にアップロードしています。
実装内容
今回実装した内容は、以下のとおりです。
index.php
へのアクセス時に、セッション Cookie を発行し、値を記録する- リクエストのたびに、有効期限を更新してセッション Cookie を発行する
session-required.php
へのアクセス時に、セッション Cookie の値が正しくない場合、400 エラーを返す- セッション Cookie の値が正しい場合、セッションに保存した値を表示する
また冒頭引用した記事にある、セッション ID によるセッション管理機能はそのまま利用できるという部分が実現可能かを検証するために、 できるだけ既存のライブラリを活用します。
今回利用したセッション管理に関するライブラリは、以下のとおりです。
- illuminate/session
- セッション操作のための処理を提供するライブラリ
- symfony/http-foundation
SessionHandlerInterface
を実装したクラスを提供するライブラリ
また JWT の生成や検証には lcobucci/jwt を利用し、オブジェクトグラフの構築には ray/di を利用しました。
クラスの依存関係
具体的なセッション操作について説明する前に、セッション操作に直接関係するクラスの依存関係についてみていきましょう。
最初に、ルートオブジェクトとなる、App クラスについてみていきます。
App クラスは、下記画像のように、TokenFactory
、SessionFactory
、RequestSessionIdProvider
の 3 つのクラスに依存しています。
3 つのクラスの役割は、以下のとおりです。
- TokenFactory
- jti にセッション ID を設定した JWT を表現するオブジェクトのインスタンスを生成する
- SessionFactory
illuminate/session
が提供するIlluminate\Session\Store
クラスのインスタンスを生成する
- RequestSessionIdProvider
- セッション Cookie からセッション ID を取得する
- セッション Cookie が存在しなかったり、JWT が改ざんされていたりした場合、新規にセッション ID を生成する
App クラスは、上記クラスのインスタンスを public プロパティとして保持し、利用側はこれらのプロパティを利用してセッションを操作します。
SessionFactory によってインスタンス生成される Illuminate\Session\Store
クラスが、セッションの操作を担うクラスです。
Illuminate\Session\Store
クラスの依存は以下のとおりで、
3 つのプリミティブ型(string)と、 SessionHandlerInterface
に依存しています。
今回、SessionHandlerInterface
の実装クラスとして、Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
を利用しているため、セッションデータは Redis に保存されます。
セッション操作
App クラスのインスタンスを生成し、セッション操作、セッション Cookie を発行するコード例は以下のとおりです(リポジトリの public/index.php
に記述されている内容です)。
// index.php <?php use NaokiTsuchiya\JwtSessionExample\App; use NaokiTsuchiya\JwtSessionExample\JwtSessionModule; use Ray\Di\Injector; require __DIR__ . '/../vendor/autoload.php'; $injector = new Injector(new JwtSessionModule()); $app = $injector->getInstance(App::class); $reqSessionId = $app->reqSessIdProvider->get(); // セッション Cookie からセッション ID を取得 $session = $app->sessionFactory->newInstance($reqSessionId); // Illuminate\Session\Store のインスタンスを生成 // セッションに値を格納 $session->start(); $session->put('key', 'value'); $session->save(); $sessId = $session->getId(); // セッション ID を取得 // JWT を生成 $token = $app->tokenFactory->newInstance($sessId); $exp = $token->claims()->get('exp'); assert($exp instanceof DateTimeImmutable); header('Content-Type: text/plain'); http_response_code(200); setcookie('sess', $token->toString(), [ 'expires' => $exp->getTimestamp(), 'path' => '/', 'domain' => 'localhost', 'secure' => false, // HTTPS 環境では true にする必要がある 'httponly' => true, ]); echo 'OK';
上記コードをビルトインサーバーなどで配信し、リクエストをすると、レスポンスにセッション Cookie が含まれていることがわかります。
$ curl --head localhost:8080/index.php HTTP/1.1 200 OK Host: localhost:8080 Date: Tue, 17 Dec 2024 07:31:16 GMT Connection: close X-Powered-By: PHP/8.3.12 Content-type: text/plain;charset=UTF-8 Set-Cookie: sess=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MzQ0MjA2NzYsIm5iZiI6MTczNDQyMDY3NiwiZXhwIjoxNzM0NTA3MDc2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJqdGkiOiJIMUpxN0FiN3JyVHB3NVo5ZXpBWFRnSGliRTFzWll0b1VEZ3NUSnZKIn0.rBAm-d_5du1JPUWk-x8kgF82pevp95oXweBklo70xBA; expires=Wed, 18 Dec 2024 07:31:16 GMT; Max-Age=86400; path=/; domain=localhost; secure; HttpOnly
セッション Cookie の sess= の値が、Base64 URL エンコードされた JWT です。JWT をデコードすると、そのペイロード部分は以下のようになっています。
{ "iat": 1734420676, "nbf": 1734420676, "exp": 1734507076, "iss": "http://localhost:8080", "jti": "H1Jq7Ab7rrTpw5Z9ezAXTgHibE1sZYtoUDgsTJvJ" }
https://jwt.io/ でも確認できます。
セッションの検証
上記で発行された Cookie を利用して、session-required.php
にアクセスします。
セッション Cookie の値が正しい場合、セッションに保存した値が表示されます。
$ curl -b "sess=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MzQ0MjA2NzYsIm5iZiI6MTczNDQyMDY3NiwiZXhwIjoxNzM0NTA3MDc2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJqdGkiOiJIMUpxN0FiN3JyVHB3NVo5ZXpBWFRnSGliRTFzWll0b1VEZ3NUSnZKIn0.rBAm-d_5du1JPUWk-x8kgF82pevp95oXweBklo70xBA" http://localhost:8080/session-required.php Session value: value
セッション Cookie の値が改ざんされたシーンを想定してみましょう。
ペイロードの jti
の値を invalid
に変更した Cookie を利用してアクセスすると、Session required.
と表示されます。
$ curl -b "sess=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MzQ0MjA2NzYsIm5iZiI6MTczNDQyMDY3NiwiZXhwIjoxNzM0NTA3MDc2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJqdGkiOiJpbnZhbGlkIn0.rBAm-d_5du1JPUWk-x8kgF82pevp95oXweBklo70xBA" http://localhost:8080/session-required.php Session required.
有効期限の切れたセッション Cookie によるアクセスも同様に Session required.
と表示されます。
以下では、ペイロードの exp
の値を 1734420676
に変更した Cookie を利用してアクセスしています。
$ curl -b "sess=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MzQ0MjA2NzYsIm5iZiI6MTczNDQyMDY3NiwiZXhwIjoxNzM0NDIwNjc2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJqdGkiOiJIMUpxN0FiN3JyVHB3NVo5ZXpBWFRnSGliRTFzWll0b1VEZ3NUSnZKIn0.0FCreNEcacboEpujKngqb7pME4wGwo9PbLUQ7P0eFeY" http://localhost:8080/session-required.php Session required.
まとめ
本記事では、セッション ID を内包した JWT を PHP で実装し、その内容について紹介しました。
検証項目としてあげていた、セッション管理機能を再利用できるか、という部分については、
Illuminate\Session\Store
や Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
を利用することで実現できたと言えるでしょう。
一方で、Illuminate\Session\Store
は PHP 標準のセッションの機能を利用しているわけではない点には、注意が必要です。
$_SESSION
に直接アクセスできませんし、session_set_save_handler
関数で登録したセッションハンドラを利用しているわけでもありません。
詳しく調査をしたわけではありませんが、Packagist で配信されているセッション管理ライブラリの多くは、PHP 標準のセッション機能を利用しているようです。
例えば、今回利用した symfony/http-foundation
や、laminas/laminas-session などがその例です。
セッション利用における標準的なインタフェースが存在しないため、仮にセッション ID を内包した JWT を利用する場合には、
独自に実装するか検討が必要です。
今回 PHP を題材にして紹介しましたが、他の言語でもセッション ID を内包した JWT を利用できると思いますので、 この記事を読んで気になった方は、ぜひご自身の得意な言語で実装してみてください。