コンテンツにスキップ

セッションストレージ

セッションの宣言では、状態が何を表すかを定義します。 ストレージ設定で決めるのは、データの保存先、期限切れレコードの削除方法、各バックエンドの 運用コストです。

判断が残るのは 5 つの配置のうち 2 つだけです。session.Sharedsession.ReadOnly は 定義上クッキーで、クライアントが読む値はクライアントまで運ばれるしかありません。 session.RequestScope は 1 リクエストのメモリの中で生まれて消え、ストレージには一切 届きません。ストアに届くのは session.Privatesession.ServerOnly です。

ブラウザが持つものは、その答えによって変わりません。トークンクッキーが運ぶのは 256 ビットの 乱数だけで、ストアの鍵はその SHA-256 ハッシュです。バックエンドのダンプが漏れても、そのまま クッキーとして再生することはできません。

[session]
enabled = true
backend = "rdb"
retention = "720h"
keyring.secret = "${SESSION_KEYRING_SECRET}"
// cmd/myapp/main.go — ストレージはオプトインなので、バックエンドは import でもあります。
import _ "github.com/shibukawa/popcornweb/sessionstore/sqlite"

よくある間違いは1つ、import を書かずにバックエンドだけ設定することです。エラーが直し方を そのまま出します。

session.backend = "rdb" needs its plugin; add to the application:
import _ "github.com/shibukawa/popcornweb/sessionstore/sqlite"

例外は cookie で、何も保存しないので import も要りません。置き場所を用意する前から セッションが動く。pw init がログインの無いプロジェクトにこれを選ぶのはそのためです。

キー 既定値 意味
enabled false true のときだけミドルウェアが走る
backend "rdb" サーバに置くスロットが使うストア
retention "720h" ストアがレコードを保持してよい期間
cookie.name "pw_session" トークンクッキー
cookie.path "/" ルートから始まること
cookie.domain (空) 空ならホスト限定。こちらが安全側
cookie.secure true 無効にしてよいのはループバックの開発時だけ
cookie.http_only true
cookie.same_site "lax" strict, lax, nonesecure 無しの none は起動時に拒否
keyring.secret (空) ブラウザが運ぶもの全部に署名し封をする
keyring.previous_secrets [] 引退した秘密鍵。読み出しには通る

retention はこのセクション唯一の期間で、セッションの寿命と取り違えやすい。別物です。

[session]
retention = "720h" # ストアがレコードを保持してよい期間
[auth]
session.ttl = "12h" # 身元の証明がどれだけ有効か

縛る対象が違うので、レコードの寿命は短いほうになります。認証をリンクしないデプロイには 前者しかなく、それでも必要です。テーブルを有界に保つスイープは期限を過ぎた行を消しますが、 期限の無いレコードは「すでに過ぎている」と読まれる。だからサーバ backend は非正の retention を起動時に拒否します。書いても読み戻せないレコードを作るより先に止めるためです。

更新には、いじる前に知っておくべき挙動が1つあります。アクティブなリクエストはアイドル期限を 延ばしますが、毎リクエストではありません。最後に見た時刻から auth.session.renewal_interval が経過してからストアに触り、絶対期限を超えることはない。ゼロのままなら、1時間のアイドル タイムアウトはセッションあたり6分に1回しか書きません。

session.ReadOnly は HMAC-SHA256 で署名し、session.Private は AES-256-GCM で封をします。 どちらも keyring.secret から用途分離した副鍵を導くので、デプロイが設定する鍵は仕組みごと ではなく1つです。

全スロットが session.Sharedsession.RequestScope でない限り必須です。rdbredisdynamofirestore の どれでも変わりません。private なスロットの匿名区間は、バックエンドにかかわらず封をした クッキーだからです。 pw initconfig.dev.toml に生成し、それ以外の環境は SESSION_KEYRING_SECRET を 読みます。pw doctor --env=prod はそこに直値があればエラーとして報告します。

previous_secrets はローテーションのためにあります。先頭の秘密が書き、退役した秘密は 読めるまま。値を持っているブラウザは失効まで持ち続けます。古い秘密を落とせば、それで 書かれたものは一斉に受け付けられなくなる。クッキーに置かれたレコードを取り消せる唯一の 手段でもあります。

ログインのあるプロジェクトの既定です。middleware.rdb.enabled = true、テーブルを作る マイグレーション、動かすエンジンの import が要ります。

キー 既定値 意味
rdb.source "middleware" middleware.rdb のプールを再利用。受け付ける値はこれだけで、他は起動時に拒否される
rdb.group (空) テーブルを持つ接続グループ。空なら write グループ
rdb.table "popcornweb_session"

SQLite、PostgreSQL、MySQL はそれぞれ自分のパッケージ(sessionstore/sqlitesessionstore/postgressessionstore/mysql)と自分の方言のマイグレーションで届きます。 起動時はテーブルを作らず検証するので、マイグレーションを飛ばしたデプロイには、どれを 適用すべきかが伝えられます。

セッションが終了ではなく放置で終わると行が溜まるので、フレームワークが10分ごとに期限切れを 掃きます。retention が正でなければならないのはこのスイープのためです。

レコードは AES-256-GCM で封をされ、トークンの隣の2つ目のクッキーに載ります。束縛は そのトークンのハッシュです。サーバには何も置かれないので、マイグレーションもスイープも ネットワーク越しの到達も要りません。

キー 既定値 意味
cookie_store.name "pw_session_data" 封をしたレコードを運ぶクッキー

このバックエンドは個別のセッションを取り消せません。 ログアウトはクライアントの コピーを失効させますが、先に取られたコピーは封の期限まで有効です。消すべきサーバ側の レコードが無い。寿命を短くすれば窓は狭まり、keyring のローテーションは未失効の全セッションを 一斉に終わらせます。session.ServerOnly のスロットを登録した状態でこれを選ぶと、起動時に スロット名を挙げて失敗します。そのスロットは、このバックエンドに出せない取り消しを 要求しているからです。

もう1つの限界はサイズです。ブラウザは約 4 KB を超えるクッキーを黙って捨てるので、ストアは 書く代わりに拒否します。session.ErrCookieTooLarge が書き込み時に返る。原因の分からない 「セッションが始まらない」より、そちらのほうがましです。

Redis と Valkey のどちらでも動き、使うのは GETSETSET XXDEL だけです。 各レコードは自分の期限から取った TTL 付きで書かれるので、放置されたセッションは勝手に 消えます。

キー 既定値 意味
redis.dsn (空) 必須redis://rediss://
redis.key_prefix "pw:session:" このストアが使用するキーの名前空間
redis.connect_timeout "5s" 起動時の ping と各コマンドの期限

起動時にサーバへ接続して ping を打つので、応答しないサーバは最初のログインではなく デプロイを止めます。鍵は設定した prefix の内側に留まり、ストアはスキャンも列挙もしません。

dynamo — リレーショナルデータベースなしで

Section titled “dynamo — リレーショナルデータベースなしで”
キー 既定値 意味
dynamo.table "popcornweb_session" 宣言上のテーブル名
dynamo.consistent_read false 強い一貫性は読み込みキャパシティを倍払う

middleware.dynamo が既に開いたクライアントを借りるので、自前のエンドポイントも 資格情報も持ちません。テーブルの TTL が死んだレコードを消し、スイープは走りません。

firestore — Google Cloud の Datastore mode

Section titled “firestore — Google Cloud の Datastore mode”
キー 既定値 意味
firestore.kind "popcornweb_session" セッションレコードを保存する entity kind

middleware.firestore が開いたクライアントを借り、各セッションを強整合で読みます。 更新時はエンティティを読み、version の precondition を付けて全体を書き直すため、 auth.session.renewal_interval ごとに 2 リクエストが発生します。

保存された期限を過ぎたセッションは、読み取り時点ですぐに無効になります。期限切れの データを削除するには、設定した kind の expires_at に Firestore の TTL ポリシーを 適用してください。フレームワークによるスイープもマイグレーションもありません。

CSRF チェックが照合する秘密値も、他と同じ登録スロットです。配置は session.Privateで、 security.csrf.enabled が有効なときだけフレームワークが宣言します。

その1つのスロットが両方の母集団を賄います。 ログインの無い訪問者は匿名区間の封をした クッキーで秘密値を受け取るので、フォームのあるページを読んだクローラが払うのはクッキーで あってレコードではありません。ログイン時のローテーションが同じスロットを設定された バックエンドへ移し、新しい秘密値を作る。サインイン前に作られたトークンをサインイン後に 提示できないのはこのためです。

クッキーは2つ同時に出ます。

クッキー 中身 HttpOnly
pw_session 不透明なセッショントークン はい
pw_csrf 秘密値から導いたマスク済みトークン いいえ

2つ目は意図的にスクリプトから読めます。リクエストを出すときにブラウザランタイムが読むから です。フレームワークのクッキーは HttpOnly、という規則の唯一の例外がこれです。

cookie rdb redis dynamo firestore
運用するストレージ なし 既にあるテーブル サービスが 1 つ増える テーブルが 1 つ増える kind が 1 つ増える
個別セッションの取り消し 不可
ペイロードのサイズ 約 3.8 KB、強制 行のサイズ レコードのサイズ item のサイズ entity のサイズ
放置された分を回収するのは 誰も。刻印が切れるだけ フレームワークのスイープ サーバーの TTL テーブルの TTL デプロイした TTL ポリシー
import 不要 sessionstore/<engine> sessionstore/redis sessionstore/dynamo sessionstore/firestore

この表は1つの問いとして読めます。このデプロイは、自分が始めていないセッションを終わらせる 必要があるか。いいえなら cookie は筋が通っていて、その他の性質は全部そこから導かれます。 はいなら、選択はレコードをどこに置くのが安いかに絞られます。既に動かしているデータベースか、 勝手に期限切れにしてくれるサーバか。

あとから答えを変えるのは、設定の編集と import の追加です。古いバックエンドで発行された セッションは移行しません。利用者はもう一度サインインします。だからこの選択は、デプロイの あとより前に済ませる価値があります。