コンテンツにスキップ

セッション

表示言語の設定と資格情報は、どちらもセッションの状態です。ただし要求は正反対でした。 前者はフロントエンドから読めてほしい。後者は読めてはならないし、アカウントを止めた瞬間に 無効になってほしい。片方はクライアントに書き換えられても入力として扱えばいい。もう片方は 書き換えられた時点で終わりです。

Popcorn Web では、そのどちらなのかを型を宣言する行で述べます。デプロイ側に残る判断は 1つだけ。サーバに置く値をどのバックエンドに預けるか、という運用者が本当に答えられる問いです。

// cmd/myapp/main.go — pw.RegisterConfig と同じく、全パッケージの init のあとで。
pw.RegisterSessionStore[Density]("density", session.Shared)
pw.RegisterSessionStore[Locale]("locale", session.ReadOnly)
pw.RegisterSessionStore[Cart]("cart", session.Private)
pw.RegisterSessionStore[Grants]("grants", session.ServerOnly)
pw.RegisterSessionStore[TokenScopes]("scopes", session.RequestScope)

キーは Go の型そのものです。だからパッケージは、レイアウトを持つパッケージを import せずに 自分の状態を読めます。名前の打ち間違いは空の値ではなくコンパイルエラーになりますし、 2つのパッケージが1つのスロットを共有したければ型を共有するしかない。共有が import グラフに 現れる、というわけです。同じ型や同じ名前を二度登録すれば、黙って上書きせずに起動時に panic します。

開発者が決めるのは第2引数だけです。

配置 クライアントは 置き場所
session.Shared 読めるし書ける 必然的に平文クッキー
session.ReadOnly 読めるが変えられない 必然的に署名付きクッキー
session.Private どちらも不可 匿名のあいだは封をしたクッキー、ログイン後は設定されたバックエンド
session.ServerOnly どちらも不可 常に設定されたバックエンド
session.RequestScope 何も; 値はそもそも移動しない プロセスのメモリに、1リクエストのあいだだけ

5行、5つの問い。順番に効きます。

  • 権威ある情報源から毎リクエスト組み立て直す値か。RequestScope です。bearer トークンを 認証データベースに照会して得るスコープ集合が代表例。取り消しが次のリクエストで見えるように 毎回読み直すのであって、どこにも保存されません。
  • フロントエンドが書き換えていい値か。Shared です。表示密度の切り替え、閉じたお知らせ、 最後に開いたタブ。デコードした値はリクエスト入力なので、クエリパラメータと同じように検証します。
  • フロントエンドが読んでいい値か。ReadOnly です。サーバが選び、クライアントは表示するだけの ロケールやテナント名。中身は読めるままなので、秘密は載せられません。
  • ログイン前から取り消せる必要があるか、あるいはクッキーに収まらないほど育ちうるか。ServerOnly です。 封をしてでもクライアントに持たせたくない保存済みの秘密 — ログインで預かるリフレッシュ トークン — や、際限なく育つ下書きがこれです。
  • それ以外は Private。これが実質の既定値です。匿名の訪問者が作り、ログイン後も持ち続ける カートがその典型です。

5行のどれにも収まらないものが1つあります。ブラウザをまたいでアカウントに付いてきてほしい preference です。セッションは1つのブラウザを指す名前で、ログアウトで消えます。ブラウザでは なくユーザーに属する状態は、アカウントをキーにしてアプリケーション自身のデータベースに 置きます。

クライアントが書き込む値をサーバに置くことは定義上できません。保存される4つのうち上2行が 設定ではなく定義としてクッキーなのはそのためで、デプロイ側に判断が残るのは PrivateServerOnly だけ。そして RequestScope は誰にも何も残しません。クッキーも、レコードも、 keyring も無しです。

配置は1つの問いです。どれだけ持つかは独立した2つ目の問いで、同じ行に書きます。

pw.RegisterSessionStore[Draft]("draft", session.Shared, session.ExpiresAfter(time.Hour))
pw.RegisterSessionStore[Density]("density", session.Shared, session.OutlivesSession(90*24*time.Hour))
pw.RegisterSessionStore[Locale]("locale", session.ReadOnly, session.OutlivesSession(session.BrowserMax))
pw.RegisterSessionStore[Cart]("cart", session.Private)

何も述べなければセッションに縛られます。サインアウトで消え、クッキーの寿命はブラウザの ウィンドウではなくセッションを追いかけます。

ExpiresAfter は配置を問わず値を早く終わらせます。レコードに置かれたスロットは自分の 期限をレコードの中に持ち、過ぎれば「無い」ものとして読まれる。セッションも他のスロットも 続きます。ローテーションする秘密や、短い admission の窓が欲しいのはこれです。

OutlivesSession はサインアウトから値を除外します。要求できるのはクッキーに置かれる 2階層だけです。親レコードを破棄すれば子レコードも失われるため、これは登録時のエラーであって ログアウト時の驚きではありません。

BrowserMax は「無期限」の正直な名前です。HTTP にその状態は存在しません。Max-Age が 無ければブラウザ終了で消え、あれば上限が掛かる。現在は 400 日です。

同じ引数が同じ保証を買わない点が1つあります。ReadOnly は署名付きなので期限は認証済み ペイロードの中にあり、古い値を後から持ち込まれても拒否できます。Shared は平文で刻印を 持たないので、寿命は Max-Age 属性だけが担う。どのみち値を書き換えられるクライアントとの 約束でしかありません。

RequestScope のスロットは、3つのオプションすべてを登録時に拒否します。寿命はリクエスト そのもので固定。別の寿命を述べるのは矛盾であって、たいていは配置だけ書き換えてオプションを 消し忘れた印です。

RequestScope は、組み立て直すコストより鮮度が大事な値のための配置です。ブラウザにも ストアにも何も届きません。Set した値は同じリクエストの残りから見えますが、トークンは 発行されず、クッキーも書かれない。次のリクエストは空から始まります。

想定している形は、ミドルウェアが情報源から事実を導き、その下のハンドラが読む、というものです。

// 認証ミドルウェアがリクエストの先頭で一度だけ、bearer トークンを
// データベースに照会する。下のハンドラはみなその結果を読む。
scopes, err := lookupScopes(r.Context(), token)
if err == nil {
if handle, ok := session.Value[TokenScopes](r.Context()); ok {
handle.Set(TokenScopes{Scopes: scopes})
}
}

値は毎リクエスト、データベースから組み立て直されます。だからそこでスコープを取り消せば、 次のリクエストにはもう反映されている。無効化すべきキャッシュはありません。コピーが 存在しないのだから。

そこが境界でもあります。組み立て直しが高くつき、限度つきの古さを許せるなら、道具が違います。 session.Privatesession.ExpiresAfter を添えれば、賞味期限を明記したキャッシュになる。 RequestScope を使うのは、古い値を読み取ること自体が許容できない場合だけです。

ブラウザには、いつ何が渡るか

Section titled “ブラウザには、いつ何が渡るか”

何かを書くまでは、何も渡りません。

func home(w http.ResponseWriter, r *http.Request) {
cart, ok := session.Load[Cart](r.Context()) // 何も発行しない
if !ok {
cart = Cart{}
}
...
}

読むだけならクッキーもレコードも発生しません。トークンを発行するのは最初の Set です。 サイト全体を巡回するだけでカートに何も入れないクローラは、あとで掃除する痕跡を残しません。

handle, ok := session.Value[Cart](r.Context())
if !ok {
// 型が未登録か、ミドルウェアが走っていない。
return
}
if err := handle.Set(cart); err != nil { // ここでトークンが出る
return
}

そのトークンは 256 ビットの乱数であって、それ以外の何でもありません。ストアの鍵はその SHA-256 ハッシュなので、バックエンドのダンプが漏れてもクッキーとしては再生できません。 ハンドラはトークンもハッシュも配置もバックエンドのクライアントも見ません。

訪問者が匿名のあいだ、private な値は封をしたクッキーに載ります。デプロイがどのバックエンドを 設定していようと、サーバの行は作られません。そしてログイン時のローテーションでそのバックエンドへ 移り、匿名クッキーは同じレスポンスで失効します。Private がまず選ぶべき配置なのはこれが理由です。

このために新しく実装したものはありません。ログインはセッション固定攻撃を潰すために元から トークンをローテーションしていて、ローテーションは元から「古いレコードを取り消し、同じ値を 持つ置き換えを作る」でした。配置の解決をプロセス起動時ではなくレコード生成時にずらす。 それだけで、昇格はもともと起きていたローテーションの副作用になります。専用のフックも、 第二の書き込み経路も、セッションが半分だけ昇格した状態の窓も生まれません。

利用者から見ると、サインイン後もカートの内容が残ります。運用者から見ると、 bot と一度きりの訪問がセッションテーブルに一切届かないことです。

無料ではありません。そして代償の落ちる場所は1箇所だけです。匿名の private な値はブラウザの クッキー予算、名前と符号化した値を合わせておよそ 3.8 KB に縛られます。超えると書き込みは 拒否されます。session.ErrCookieTooLarge がスロット名を挙げて返る。裏でサーバに逃がしたりは しません。匿名区間に無制限に育ちうるものは ServerOnly を宣言して、最初の書き込みから行を払います。

ログアウトは全部を終わらせる

Section titled “ログアウトは全部を終わらせる”

auth のログアウトはセッションを破棄します。全レコードを取り消し、セッションが持つクッキーを 失効させる。OutlivesSession を宣言したスロット以外の全部です。匿名のカートは道連れ。 表示言語は残ります。RequestScope の値はそのリクエスト内では手つかずです。セッションは 何も預かっていないので、取り返すものが無い。どのみち次のリクエストには残りませんが。

そもそもどのセッションにも属さないクッキーでは、session.Jar を直接使うこともできます。 この例外的な経路はこのページの末尾で扱います。サインインヒントが その一例で、すでに終わったセッションについて語るため、そのセッションと寿命を共有できません。

トークンが無いのは、まだセッションを持たないブラウザであって、失敗ではありません。 壊れているか期限切れのものは消され、リクエストはセッション無しで続きます。

到達できないバックエンドは話が別です。ミドルウェアは黙って匿名に降格させず、503 を返します。 「データベースが落ちている」と「サインインしていない」が、何を表示するか決めるアプリケーションから 同じに見えてはいけません。

セッションミドルウェアを設置するのはフレームワークで、plugin/auth はそれを駆動します。 認証を import しないプロジェクトでもスロットは宣言できますし、読み書きもできます。得られない のは寿命です。誰も宣言しないので。トークンクッキーはブラウザが保持するあいだ生き、絶対期限は 刻まれません。

ここまでがハンドラから見える範囲です。サーバに置くスロットが実際にどこへ着地し、何が それを縛り、各バックエンドに何がかかり、署名と封をする鍵がどれか。これらはまとめて1つの デプロイ判断で、セッションストレージにあります。

新しいアプリケーション状態には pw.RegisterSessionStore を使ってください。値の置き場所を 型で決め、セッションの寿命とログアウト規則を適用し、ブラウザに読み書きをどこまで許すかも 配置で表せます。クッキーの Jar を直接作るのは、このモデルの高機能版ではなく、モデルから 外れる選択です。

session.Jar を直接使うのは、そのクッキーをセッションのライフサイクルから独立させる必要が ある場合だけです。典型的なのは、古いコードやクライアントが残るあいだ既存のクッキー形式を 維持する場合と、独自のクッキーを定義済みのプロトコルと相互運用する場合です。plugin/auth の サインインヒントは、フレームワーク内の一例です。

type LegacyPreference struct {
Density string `json:"density"`
}
preferences, err := session.NewJar[LegacyPreference](nil, session.JarOptions{
Mode: session.CookiePlain,
Cookie: session.CookieOptions{Name: "legacy_density", Secure: true, HTTPOnly: true},
MaxAge: 30 * 24 * time.Hour,
})
if err != nil {
return err
}
handler = preferences.Middleware()(handler)

ミドルウェアを通すと、リクエストコンテキストから ReadValue().SetClear を使えます。 維持すべきクッキーの契約に合わせて CookiePlainCookieSignedCookieSealed を選びます。 plain の値はクライアント入力なので検証が必要です。書き込みはレスポンスボディより前に行い、 容量はおよそ 3.8 KB に収める必要があります。

独立したクッキーを必要とする既存の契約が無ければ、代わりに session.Sharedsession.ReadOnlysession.Private のどれかを宣言してください。同じクライアントアクセスの 判断を表しつつ、値をセッションのライフサイクル下に置けます。