Flask のセッション Cookie の値の参照と設定

## 概要

Flask アプリケーションのセッション Cookie に格納されている値を参照したり、設定したりするのに必要な基礎知識を示します。又、その知識に基づいて、Flask アプリケーションの自動テストのフィクスチャとして利用できるカスタムテストクライアントの実装例も示します。

### 対象読者

Flask アプリケーションの自動テストを実装する時、テストクライアントに格納されるセッション Cookie の内容をリクエスト前に設定したり、リクエスト後に確認したりしたいと思ったことがある方を主な対象読者とします。

* 例えば、既にログインしている状態で再度ログインしようとした場合の振る舞いをテストしたいので、リクエスト前にテストクライアントへセッション Cookie を設定したい。
* 例えば、新しいセッションへのログイン後に既存のセッションがクリアされているかをテストしたいので、リクエスト後にテストクライアントのセッション Cookie を確認したい。

## セッション Cookie の基礎知識

セッション Cookie は、テストクライアントの `cookie_jar` に Python 標準ライブラリの `http.cookiejar.Cookie` オブジェクトとして格納されています。`cookie_jar` (Cookie Jar) とは何かは、Flask 固有の概念ではないので省略します。

https://docs.python.org/ja/3/library/http.cookiejar.html#cookie-objects

`app` 変数にテスト用の Flask アプリケーションインスタンスが格納されているとして、次のような操作でセッション Cookie を取得できます。尚、セッション Cookie の名前はディフォルトの `session` としています。

```python
client = app.test_client()
session_cookie = next((cookie for cookie in client.cookie_jar if cookie.name == 'session'), None)
```

このセッション Cookie には、Flask の `session` オブジェクトを `itsdangerous.url_safe.URLSafeTimedSerializer` （以降、`URLSafeTimedSerializer` と略) でシリアライズした値が格納されています。itsdangerous は、Flask の依存パッケージの 1 つです。

https://itsdangerous.palletsprojects.com/

`URLSafeTimedSerializer` でシリアライズした Flask の `session` オブジェクトと言っても分かりづらいので、値の例を示します。`session_cookie` の値は、`session_cookie.value` プロパティで取り出すことができます。

```text
eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw.71WaB9ScNnkwdSRNS3HCYqmw7j8
```

人間が直接操作するには扱いづらいことが分かると思います。JWT や OAuth に詳しい方なら、どこかでみたことがある形式だと思うかもしれません。

このセッション Cookie の値は、". (ドット)" で区切られた 3 つの部分「ペイロード.タイムスタンプ.署名」から成ります。

```python
>>> value = 'eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw.71WaB9ScNnkwdSRNS3HCYqmw7j8'.split('.') 
>>> print(value)
['eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0', 'Y_ckkw', '71WaB9ScNnkwdSRNS3HCYqmw7j8']
```

ペイロードは、URL セーフな Base64 エンコードされた　Flask の `session` オブジェクトです。Python 標準ライブラリに含まれる `base64` モジュールを使用してデコードすることで　Flask の `session` オブジェクトの JSON 表現のバイト配列を取得できます。

```python
>>> payload = value[0]
>>> import base64
>>> payload_decoded = base64.urlsafe_b64decode(payload + '===')
>>> print(payload_decoded)
b'{"token":"1a2b3c4d5e6f7g8h9i0j1l2m3n4o5p6q"}'
```

セッション Cookie には、キー "`token`" とその値 "`1a2b3c4d5e6f7g8h9i0j1l2m3n4o5p6q`" が格納さえていたことが分かります。セッション Cookie に含まれるペイロードが暗号化されておらず、簡単に内容を確認できることも分かります。

補足説明： デコード前に `payload` へ "`===`" を足しているのは、Base64 を `base64.urlsafe_b64decode` でデコードするには、文字列長が 4 の倍数でなければならず、4 の倍数に満たない場合は Base64 の仕様で指定されている "`=` (イコール)" によるパッディングが必要だからです。4 の倍数に満たない場合はエラーが発生してデコードに失敗しますが、はみ出た "`=`" は無視されるため、常に "`===`"　を足します。

ペイロードを JSON として取得したい場合は、バイト配列をデコードし、Python 標準ライブラリの `json` パッケージでロードします。

```python
>>> import json
>>> payload_json = json.loads(payload_decoded.decode())
>>> print(payload_json)
{'token': '1a2b3c4d5e6f7g8h9i0j1l2m3n4o5p6q'}
```

タイムスタンプも、ペイロード同様に URL セーフな Base64 エンコードされた整数値です。

```python
>>> timestamp = value[1]
>>> timestamp_decoded = base64.urlsafe_b64decode(timestamp + '===')
>>> timestamp_int = int.from_bytes(timestamp_decoded, byteorder='big')
>>> print(timestamp_int)
1677141139
```

タイムスタンプとして "`1677141139`" が格納されていたことが分かります。このタイムスタンプは、セッション Cookie を作成しようとした (署名しようとした) 時点を示します。Flask の内部では、セッション Cookie に明示的な有効期限を定めている場合、現在時刻がこのタイムスタンプから `PERMANENT_SESSION_LIFETIME` より隔たっていないかでセッション Cookie の有効期限を確認しています。

補足説明： ペイロードと異なり、整数値なので Base64 でデコードした後に `byte` 型から `int` 型への変換もおこなっています。

タイムスタンプを日付として取得したい場合は、Python 標準ライブラリの `datetime` パッケージで変換します。

```python
>>> from datetime import datetime
>>> timestamp_datetime = datetime.fromtimestamp(timestamp_int)
>>> print(timestamp_datetime)
2023-02-23 17:32:19
```

署名は、URL セーフな Base64 エンコードされたままの状態の「ペイロード.タイムスタンプ (`eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw`)」を Flask の `SECRET_KEY` で署名した値です。

署名は、復元するものではないので、復元の行程は示せません。署名と言われてピンとこない方は、電子署名とは何かを調べてみてください。

https://ja.wikipedia.org/wiki/%E9%9B%BB%E5%AD%90%E7%BD%B2%E5%90%8D

ここまで、逐次セッション Cookie をデシリアライズしてきましたが、Flask の内部では `URLSafeTimedSerializer` の `dumps` と `loads` メソッドを使用することで、一括してセッション Cookie のシリアライズとデシリアライズをおこなっています。

```python
>>> import flask
>>> import flask.sessions
>>> app = flask.Flask(__name__)
>>> app.secret_key = 'dev'
>>> client = app.test_client()
>>> serializer = flask.sessions.SecureCookieSessionInterface().get_signing_serializer(client.application)
>>> serializer.loads('eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw.71WaB9ScNnkwdSRNS3HCYqmw7j8')
{'token': '1a2b3c4d5e6f7g8h9i0j1l2m3n4o5p6q'}
```

`URLSafeTimedSerializer` は直接インスタンス化することもできますが、`flask.sessions.SecureCookieSessionInterface` を介してインスタンス化することで Flask アプリケーションの設定に基づいたインスタンスをより簡単に取得しています。直接インスタンス化する方法に興味のある方は、`flask.sessions.SecureCookieSessionInterface` の `get_signing_serializer` メソッドを参照してみてください。ソルトやダイジェストメソッドなどを巧みに指定してシリアライザーを作成していることを確認できます。

逆向きのシリアライズは、`dumps` メソッドで実現できます。

```python
>>> serializer.dumps(payload_json)
'eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_kQUQ.NoLD_J6V2OVTQcAhC65Psq0Rp3E'
```

タイムスタンプはシリアライズしようとした日時になるため、タイムスタンプと署名部分が変わってしまいますが、ペイロード部分が一致することを確認できます。

## カスタムテストクライアントの実装例

Flask では、`pytest` や Python 標準ライブラリの `unittest` といった自動テストで使用するためのテストクライアントを提供しています。

```python
@pytest.fixture
def client(app: Flask) -> FlaskClient:
    return app.test_client()
```

このテストクライアントは、ディフォルトでは `flask.testing.FlaskClient` クラスが使用されますが、機能をカスタマイズしたオリジナルのクラスに差し替えることができます。

テストの実装を簡単にするためにセッション Cookie を参照したり、設定したりできるカスタムテストクライアントを実装してみたので公開します。

```python
from datetime import datetime
from http.cookiejar import Cookie
from typing import Any, Optional, Union

import pytest
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
from flask.testing import FlaskClient


@pytest.fixture
def client(app: Flask) -> CustomFlaskClient:
    # テストクライアントのクラスを CustomFlaskClient クラスへ差し替え
    app.test_client_class = CustomFlaskClient
    return app.test_client()


class CustomFlaskClient(FlaskClient):
    def __init__(self, *args: Any, **kwargs: Any):
        super().__init__(*args, **kwargs)
        self._session_interface = SecureCookieSessionInterface()

    @property
    def session_cookie(self) -> Cookie:
        if self.cookie_jar is None:
            return None

        return next((
            cookie for cookie in self.cookie_jar if cookie.name == self._session_interface.get_cookie_name(self.application)
        ), None)

    def dump_session_cookie(self) -> Any:
        session_cookie = self.session_cookie

        if session_cookie is None:
            return None

        return self._session_interface.get_signing_serializer(self.application).loads(session_cookie.value)

    def load_session_cookie(self, value: Any = None, expires: Optional[Union[str, datetime, int, float]] = None) -> None:
        server_name = self.application.config.get('SERVER_NAME', None)

        if server_name is None:
            server_name = '127.0.0.1'

        self.set_cookie(
            server_name=server_name,
            key=self._session_interface.get_cookie_name(self.application),
            value='' if value is None else self._session_interface.get_signing_serializer(self.application).dumps(value),
            expires=expires,
            path=self._session_interface.get_cookie_path(self.application),
            domain=self._session_interface.get_cookie_domain(self.application),
            secure=self._session_interface.get_cookie_secure(self.application),
            httponly=self._session_interface.get_cookie_httponly(self.application),
            samesite=self._session_interface.get_cookie_samesite(self.application),)
```

`dump_session_cookie` メソッドで、セッション Cookie の Base64 エンコードされている値を Flask の `session` オブジェクト相当の `dict` オブジェクトとして参照できます。

`load_session_cookie` メソッドで、`dict` オブジェクトを Base64 エンコードされている値としてセッション Cookie へ設定できます。