DjangoフレームワークのセッションデータをPHPから読み書きする

実際に必要になったわけではないので、遊びみたいなものなのですが、Djangoのセッションの仕組みを理解しておくと、こういうこともできるよ、という例のために、DjangoのセッションデータをPHPから読み書きするのをやってみました。

Djangoのセッションの概要

最初にDjangoのセッション機能についておさらいです。

Djangoのセッションについてのドキュメントは以下の通り:

セッションの使いかた | Django documentation | Django

Djangoのセッション機能は、複数の異なるHTTPリクエストにおいて、同一のブラウザ(閲覧者)からのリクエストの場合に一連の「セッション」として取り扱って、データを保持する機能を提供します。

HTTPとセッションについてはここでは説明しませんが、よくあるウェブアプリケーションフレームワークのセッション機能と同様です。

request.session が辞書ライクなオブジェクトになっており、これを読み書きできます。同一ブラウザからの異なるリクエストでこのセッションデータは保持されます。

class SpamPageView(View):
    def get(self, request):
        my_counter = request.session.get("my_counter", 0)  # セッションに保存した値の取得
        my_counter += 1
        request.session["my_counter"] = my_counter  # セッションに値を保存
        ...

セッションはユーザー認証などでも利用されています。Djangoの認証機能を使った場合は、ユーザーがログインするとこのセッションにユーザーIDなどが保持されます。

Djangoのセッションはバックエンドの実装を切り替えることで、保存先や各種挙動を変更できます。

デフォルトの動作

カスタマイズしないデフォルト設定の場合のDjangoのセッションは以下のような挙動です。

  • request.session に変更があった場合に、Djangoのセッションミドルウェアでデータが保存され、レスポンスに Set-Cookie ヘッダーでセッションIDが追加される
    • セッションIDはCookieに保存される。Cookieのキーは sessionid
  • セッションデータはデータベース上の django_session テーブルに保存される
    • カラムは session_key session_data expire_date の3つ
  • セッションデータ(request.session の辞書ライクなオブジェクト)は django.core.signing により、シリアライズ+暗号署名が付与される
    • Pythonの辞書をJSONエンコードJSONをzlibで圧縮、base64により文字列化、タイムスタンプをbase62で文字列にして付与、HMAC-sha256にてハッシュ値(base64)を生成、これを : で連結
    • つまり、改ざん検知用のハッシュ値付きで保存されている、ただし暗号化されているわけではない

データベースに保存されたセッションデータの確認

Djangoのプロジェクトを作って、管理者ユーザーを作成し、Django管理画面にログインすると、セッションデータがデータベースに追加されます。

この状態で、Djangoシェルから、セッションデータを確認してみると、以下のような文字列になっています。

>>> session = Session.objects.first()
>>> session.session_data
'.eJxVjEEOwiAQRe_C2pAOLQO4dO8ZyJQZpGpKUtqV8e7apAvd_vfef6lI21ri1mSJE6uzAnX63UZKD5l3wHeab1WnOq_LNOpd0Qdt-lpZnpfD_Tso1Mq3loxsAY1kAIdB2HqhhM5yQEY2wbnOZw8dhcweiIy3QwYm7vswIKr3B-nLN8Y:1uCK0q:WMjyqXdLN94dX2CVYdckucQvJari-41kMairMphjvmI'

先頭1文字目のドット(.)は圧縮フラグを表します。ドットがついていればデータはzlibで圧縮されています。

区切り文字はコロン(:)です。 .[データ]:[タイムスタンプ]:[署名] というフォーマットになります。

Djangoのアプリ内でそのまま読み書きするのであれば、APIの実装があるため、 session.get_decoded() のようにすれば、デコードされた辞書オブジェクトを取得できます。

>>> session.get_decoded()
{'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'ef6d5162ef11769ed58eac675d96d6d297708f810a9fd81aa2854f1dad339466'}

この処理は django.core.signing モジュールの機能を内部的に使っていますが、一旦改ざん検知は置いといて、Pythonの標準モジュールの機能だけでデコードをしてみます。

>>> import base64
>>> import json
>>> import zlib
>>> json.loads(zlib.decompress(base64.urlsafe_b64decode(session.session_data[1:].split(':', 1)[0] + "=")))
{'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'ef6d5162ef11769ed58eac675d96d6d297708f810a9fd81aa2854f1dad339466'}

簡単ですね。

base64については、URLセーフに対応する関数を使ってデコードしています。これはエンコード時に django.core.signing がURLセーフに対応するために urlsafe_b64encode を使っているためです。パディングのところはこの例だと雑に = を付与しています。

上記のようにデコードできるので、 Djangoのセッションデータは暗号化はされていない という点に気を付けておく必要があります。

署名による改ざん検知はできますが、暗号化はされていないため、セッションに保存する内容や保存先については気をつける必要があります。

PHP側で読み書きするコード

前置きが長くなりましたが、PHPで先程のセッションデータをデコードするのはさほど難しくはないので、読み取りだけであれば、実装は簡単です。

しかし、PHP側でセッションデータの更新をする場合、Django側は改ざん検知の仕組みがあるため、PHP側でも同じアルゴリズムシグネチャを生成して付与する必要があり、これは結構複雑です。

生成AIに手伝ってもらいながら、 django.core.signing と同等の機能を持つクラスを作ってみました。

signing.php:

<?php

function b62_encode(int $num): string
{
  if (!is_int($num) || $num < 0) {
    throw new InvalidArgumentException("Only non-negative integers allowed");
  }

  $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  $base = 62;

  if ($num === 0) {
    return '0';
  }

  $result = '';
  while ($num > 0) {
    $result = $chars[$num % $base] . $result;
    $num = intdiv($num, $base);
  }

  return $result;
}

function b62_decode(string $str): int
{
  $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  $base = 62;

  $num = 0;
  $len = strlen($str);
  for ($i = 0; $i < $len; $i++) {
    $pos = strpos($chars, $str[$i]);
    if ($pos === false) {
      throw new InvalidArgumentException("Invalid character in input: " . $str[$i]);
    }
    $num = $num * $base + $pos;
  }

  return $num;
}

function b64_encode($data): string
{
  if (!is_string($data)) {
    throw new InvalidArgumentException("Only strings allowed");
  }

  return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function b64_decode($input)
{
  $remainder = strlen($input) % 4;
  if ($remainder) {
    $padlen = 4 - $remainder;
    $input .= str_repeat('=', $padlen);
  }
  return base64_decode(strtr($input, '-_', '+/'));
}

function salted_hmac($key_salt, $value, $secret_key, $algorithm = 'sha1')
{
  if (!is_string($value)) {
    $value = strval($value);
  }
  $key = hash($algorithm, $key_salt . $secret_key, true);
  $hmac = hash_hmac($algorithm, $value, $key, true);
  return $hmac;
}

class Signer
{
  protected string $sep;
  protected string $salt;
  protected string $secret;
  protected string $algorithm;

  public function __construct(string $secret, string $salt = 'django.core.signing.Signer', string $sep = ':', string $algorithm = 'sha256')
  {
    if (preg_match('/[' . preg_quote($sep, '/') . ']/', $salt)) {
      throw new InvalidArgumentException("Salt cannot contain the separator character");
    }
    $this->secret = $secret;
    $this->salt = $salt;
    $this->sep = $sep;
    $this->algorithm = $algorithm;
  }

  protected function get_signature(string $value): string
  {
    return b64_encode(salted_hmac($this->salt . 'signer', $value, $this->secret, $this->algorithm));
  }

  public function sign(string $value): string
  {
    return $value . $this->sep . $this->get_signature($value);
  }

  public function unsign(string $signed_value): string
  {
    $sep_pos = strrpos($signed_value, $this->sep);
    if ($sep_pos === false) {
      throw new RuntimeException("Bad signature");
    }

    $value = substr($signed_value, 0, $sep_pos);
    $sig = substr($signed_value, $sep_pos + strlen($this->sep));

    $expected_sig = $this->get_signature($value);

    if (!hash_equals($expected_sig, $sig)) {
      throw new RuntimeException("Signature does not match");
    }

    return $value;
  }
}

class TimestampSigner extends Signer
{
  protected string $timestamp_salt = 'django.core.signing.TimestampSigner';

  public function make_timestamp(): string
  {
    return b62_encode(time());
  }

  public function sign(string $value): string
  {
    $timestamp = $this->make_timestamp();
    $value_with_ts = $value . $this->sep . $timestamp;
    return parent::sign($value_with_ts);
  }

  public function unsign(string $signed_value, int|null $max_age = null): string
  {
    $result = parent::unsign($signed_value);
    $parts = explode($this->sep, $result);
    if (count($parts) !== 2) {
      throw new RuntimeException("Bad signature format");
    }

    [$value, $ts_b62] = $parts;

    if ($max_age !== null) {
      $timestamp = b62_decode($ts_b62);
      $age = time() - $timestamp;
      if ($age > $max_age) {
        throw new RuntimeException("Signature has expired");
      }
    }

    return $value;
  }

  public function timestamp(string $signed_value): int
  {
    $parts = explode($this->sep, $signed_value);
    if (count($parts) !== 3) {
      throw new RuntimeException("Bad signature format");
    }
    return b62_decode($parts[1]);
  }
}

function django_signer_dumps($value, string $secret, string $salt, bool $compress = false, bool $add_timestamp = false): string
{
  $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

  if ($compress) {
    $data = zlib_encode($json, ZLIB_ENCODING_DEFLATE);
  } else {
    $data = $json;
  }

  $b64 = b64_encode($data);
  // add a dot to the beginning of the string if compress is true
  if ($compress) {
    $b64 = '.' . $b64;
  }

  if ($add_timestamp) {
    $signer = new TimestampSigner($secret, $salt);
  } else {
    $signer = new Signer($secret, $salt);
  }

  return $signer->sign($b64);
}

function django_signer_loads(string $signed_value, string $secret, string $salt, int|null $max_age = null)
{
  // Use appropriate signer
  if (substr_count($signed_value, ':') === 2) {
    $signer = new TimestampSigner($secret, $salt);
    $b64 = $signer->unsign($signed_value, $max_age);
  } else {
    $signer = new Signer($secret, $salt);
    $b64 = $signer->unsign($signed_value);
  }

  // first character is a dot, indicating compression
  $is_compressed = false;
  if (strlen($b64) > 0 && $b64[0] === '.') {
    $is_compressed = true;
    $b64 = substr($b64, 1);
  }

  $raw = b64_decode($b64);
  if ($is_compressed) {
    $json = zlib_decode($raw);
  } else {
    $json = $raw;
  }

  if ($json === false) {
    throw new RuntimeException("Base64 decoding failed");
  }

  $data = json_decode($json, true);

  if (json_last_error() !== JSON_ERROR_NONE) {
    throw new RuntimeException("JSON decoding failed: " . json_last_error_msg());
  }

  return $data;
}

こんな感じです。PHPの組み込みの関数では足りないURLセーフなbase64エンコード、デコードの機能も実装してあります。

signing.phpの利用

作成したモジュールに含まれる django_signer_loads はセッションデータをデコードする関数で、Djangodjango.core.signing.loads とおおむね同等です。

エンコードdjango_signer_dumps を使います。

<?php
require_once('/signing.php');

// 中略

// セッションデータをデコード
$session = django_signer_loads($session_data, APP_SECRET_KEY, APP_SESSION_SALT);

// 中略

// セッションデータをエンコード
$session_data = django_signer_dumps($session, APP_SECRET_KEY, APP_SESSION_SALT, true, true);

APP_SECRET_KEY の部分は、Django側の settings.pySECRET_KEY と同じ文字列を指定します。

APP_SESSION_SALT の部分は、 "django.contrib.sessions.SessionStore" を常に指定します。

サンプルコードの動作

サンプルコード全体は以下に置いています。

https://github.com/tokibito/sample_nullpobug/tree/main/django/django-shared-session

Djangoはデフォルト設定だとsqlite3のデータベースを使い、セッションもそこに保存されるので、PHPからもPDOでsqlite3を読み書きする形にしました。

  • nginxでリバースプロキシしていて、同一ドメイン名のサブディレクトリでDjangoPHPがそれぞれ動作するような形
  • /php/ 以下はPHPのアプリが動作します。
  • それ以外のパスではDjangoのアプリが動作します。

docker-composeで動かせます。あらかじめDjango側の manage.py createsuperuseradmin という名前のユーザーを作っておいて、ブラウザで http://localhost/ にアクセスします。

1. ログイン画面

Djangoの認証フレームワークでログイン画面を作っています。UIは django-bootstrap5 を使っているので、bootstrapの見た目です。

2. Django側の画面

ログインすると、Django側の画面を表示します。ログイン中のユーザー名と、セッションデータを表示しています。

3. PHP側の画面

PHP側の画面へ」のリンク先はPHPのアプリのページになります。Djangoが保存したセッションデータを同じsqlite3のデータベースファイルから取得し、デコードして表示しています。

また、セッションデータの更新の例として、PHP側のページにアクセスする毎に、 counter というキーでセッション内に数値をカウントアップしています。

4. Django側の画面(PHP側で更新したセッションデータの確認)

  • パス: /django/

PHP側のページにアクセスしたあと、Django側のページを再度表示すると、PHP側で counter キーで保存した値をDjango側でも確認できます。

まとめ

  • Djangoのセッションデータは署名付きでエンコードされて保存される
  • PHPから読み取るのは簡単だが、更新をする場合は署名の生成も必要となる
  • Django側の仕様で保存されたセッションデータをPHPで読み取り、更新することができた

こういうコードを書いてみて、Djangoのセッションの仕組みや、signerについて理解が進みました。

このような実装を実際に使うことは基本的にないと思いますが、仕組みとしては面白いですね。