弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

セッション ID を内包した JWT を PHP で実装する

ブログ記事のタイトル「セッション ID を内包した JWT を PHP で実装する」と書かれている

この記事は弁護士ドットコム 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 によるセッション管理機能はそのまま利用できるという部分が実現可能かを検証するために、 できるだけ既存のライブラリを活用します。

今回利用したセッション管理に関するライブラリは、以下のとおりです。

また JWT の生成や検証には lcobucci/jwt を利用し、オブジェクトグラフの構築には ray/di を利用しました。

クラスの依存関係

具体的なセッション操作について説明する前に、セッション操作に直接関係するクラスの依存関係についてみていきましょう。

最初に、ルートオブジェクトとなる、App クラスについてみていきます。

App クラスは、下記画像のように、TokenFactorySessionFactoryRequestSessionIdProvider の 3 つのクラスに依存しています。

App クラスの依存関係
App クラスの依存関係

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 に依存しています。

Illuminate\Session\Store クラスの依存関係
Illuminate\Session\Store クラスの依存関係

今回、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\StoreSymfony\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 を利用できると思いますので、 この記事を読んで気になった方は、ぜひご自身の得意な言語で実装してみてください。