実際に必要になったわけではないので、遊びみたいなものなのですが、Djangoのセッションの仕組みを理解しておくと、こういうこともできるよ、という例のために、DjangoのセッションデータをPHPから読み書きするのをやってみました。
最初に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が追加される
- セッションデータはデータベース上の
django_session
テーブルに保存される
- カラムは
session_key
session_data
expire_date
の3つ
- セッションデータ(
request.session
の辞書ライクなオブジェクト)は django.core.signing
により、シリアライズ+暗号署名が付与される
データベースに保存されたセッションデータの確認
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);
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)
{
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);
}
$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
はセッションデータをデコードする関数で、Djangoの django.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.py
の SECRET_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を読み書きする形にしました。
docker-composeで動かせます。あらかじめDjango側の manage.py createsuperuser
で admin
という名前のユーザーを作っておいて、ブラウザで http://localhost/
にアクセスします。
1. ログイン画面
Djangoの認証フレームワークでログイン画面を作っています。UIは django-bootstrap5
を使っているので、bootstrapの見た目です。

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

3. PHP側の画面
「PHP側の画面へ」のリンク先はPHPのアプリのページになります。Djangoが保存したセッションデータを同じsqlite3のデータベースファイルから取得し、デコードして表示しています。
また、セッションデータの更新の例として、PHP側のページにアクセスする毎に、 counter
というキーでセッション内に数値をカウントアップしています。

4. Django側の画面(PHP側で更新したセッションデータの確認)
PHP側のページにアクセスしたあと、Django側のページを再度表示すると、PHP側で counter
キーで保存した値をDjango側でも確認できます。

まとめ
- Djangoのセッションデータは署名付きでエンコードされて保存される
- PHPから読み取るのは簡単だが、更新をする場合は署名の生成も必要となる
- Django側の仕様で保存されたセッションデータをPHPで読み取り、更新することができた
こういうコードを書いてみて、Djangoのセッションの仕組みや、signerについて理解が進みました。
このような実装を実際に使うことは基本的にないと思いますが、仕組みとしては面白いですね。