概要
Flask アプリケーションのセッション Cookie に格納されている値を参照したり、設定したりするのに必要な基礎知識を示します。又、その知識に基づいて、Flask アプリケーションの自動テストのフィクスチャとして利用できるカスタムテストクライアントの実装例も示します。
対象読者
Flask アプリケーションの自動テストを実装する時、テストクライアントに格納されるセッション Cookie の内容をリクエスト前に設定したり、リクエスト後に確認したりしたいと思ったことがある方を主な対象読者とします。
- 例えば、既にログインしている状態で再度ログインしようとした場合の振る舞いをテストしたいので、リクエスト前にテストクライアントへセッション Cookie を設定したい。
- 例えば、新しいセッションへのログイン後に既存のセッションがクリアされているかをテストしたいので、リクエスト後にテストクライアントのセッション Cookie を確認したい。
セッション Cookie の基礎知識
セッション Cookie は、テストクライアントの cookie_jar
に Python 標準ライブラリの http.cookiejar.Cookie
オブジェクトとして格納されています。cookie_jar
(Cookie Jar) とは何かは、Flask 固有の概念ではないので省略します。
app
変数にテスト用の Flask アプリケーションインスタンスが格納されているとして、次のような操作でセッション Cookie を取得できます。尚、セッション Cookie の名前はディフォルトの session
としています。
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 つです。
URLSafeTimedSerializer
でシリアライズした Flask の session
オブジェクトと言っても分かりづらいので、値の例を示します。session_cookie
の値は、session_cookie.value
プロパティで取り出すことができます。
eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw.71WaB9ScNnkwdSRNS3HCYqmw7j8
人間が直接操作するには扱いづらいことが分かると思います。JWT や OAuth に詳しい方なら、どこかでみたことがある形式だと思うかもしれません。
このセッション Cookie の値は、". (ドット)" で区切られた 3 つの部分「ペイロード.タイムスタンプ.署名」から成ります。
>>> value = 'eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_ckkw.71WaB9ScNnkwdSRNS3HCYqmw7j8'.split('.')
>>> print(value)
['eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0', 'Y_ckkw', '71WaB9ScNnkwdSRNS3HCYqmw7j8']
ペイロードは、URL セーフな Base64 エンコードされた Flask の session
オブジェクトです。Python 標準ライブラリに含まれる base64
モジュールを使用してデコードすることで Flask の session
オブジェクトの JSON 表現のバイト配列を取得できます。
>>> 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
パッケージでロードします。
>>> import json
>>> payload_json = json.loads(payload_decoded.decode())
>>> print(payload_json)
{'token': '1a2b3c4d5e6f7g8h9i0j1l2m3n4o5p6q'}
タイムスタンプも、ペイロード同様に URL セーフな Base64 エンコードされた整数値です。
>>> 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
パッケージで変換します。
>>> 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
で署名した値です。
署名は、復元するものではないので、復元の行程は示せません。署名と言われてピンとこない方は、電子署名とは何かを調べてみてください。
ここまで、逐次セッション Cookie をデシリアライズしてきましたが、Flask の内部では URLSafeTimedSerializer
の dumps
と loads
メソッドを使用することで、一括してセッション Cookie のシリアライズとデシリアライズをおこなっています。
>>> 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
メソッドで実現できます。
>>> serializer.dumps(payload_json)
'eyJ0b2tlbiI6IjFhMmIzYzRkNWU2ZjdnOGg5aTBqMWwybTNuNG81cDZxIn0.Y_kQUQ.NoLD_J6V2OVTQcAhC65Psq0Rp3E'
タイムスタンプはシリアライズしようとした日時になるため、タイムスタンプと署名部分が変わってしまいますが、ペイロード部分が一致することを確認できます。
カスタムテストクライアントの実装例
Flask では、pytest
や Python 標準ライブラリの unittest
といった自動テストで使用するためのテストクライアントを提供しています。
@pytest.fixture
def client(app: Flask) -> FlaskClient:
return app.test_client()
このテストクライアントは、ディフォルトでは flask.testing.FlaskClient
クラスが使用されますが、機能をカスタマイズしたオリジナルのクラスに差し替えることができます。
テストの実装を簡単にするためにセッション Cookie を参照したり、設定したりできるカスタムテストクライアントを実装してみたので公開します。
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 へ設定できます。