4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-02-23

概要

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 の内部では URLSafeTimedSerializerdumpsloads メソッドを使用することで、一括してセッション 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.SecureCookieSessionInterfaceget_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 へ設定できます。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?