TT Lab
はじめる
学ぶ 学習パス コース

セッションとトークン — ブラウザからメッシュまで

BFF でトークンをブラウザの外に置く

TT Labで続きを見る

目標

1つのPodの中に発行者(8312)・下流API(8311)・BFF(8310)を立て、access tokenがブラウザーから見える場所に一度も現れないこと、そしてBFFを経由した呼び出しだけが下流で通ることを、実際のHTTPリクエストで証明します。

なぜ重要なのか

SPAがトークンを直接持つと、そのページで動くスクリプトが1つ汚染されるだけでトークンが外に出てしまい、攻撃者は有効期限までそのトークンを使えます。RFC 10017(OAuth 2.0 for Browser-Based Applications)6.1節のBFFは、トークンをサーバー側のセッションだけに置き、ブラウザーにはHttpOnlyのセッションCookieだけを渡します。BFFは機密クライアントとして、コードをトークンに交換し、下流へ渡すときにCookieを外してBearerトークンを付けます。この構造は、1か所でもゆるいと崩れます。セッション確認のレスポンスにトークンを載せる、下流がトークンなしのリクエストを受け付ける、ログアウトでトークンを取り消さない、のどれでもBFFは名前だけになります。このラボは、その3つの隙間をそれぞれリクエストで確認させます。

ステップ

  1. /root/st/bff/app.py1つで、発行者(127.0.0.1:8312)、下流API(127.0.0.1:8311)、BFF(127.0.0.1:8310)を起動してください。3つともGET /healthは200と{"ok":true}を返します。
  2. GET /login→発行者のPOST /authorize→BFFの/callbackの順でログインし、コールバックが返したSet-Cookieの1行を/root/st/bff/cookie.txtに保存してください。Cookieは__Host-Http-bffという名前で、HttpOnly、Secure、SameSite=Strict、Path=/を持ちます。
  3. Redisのbff:sess:<세션ID>(プレースホルダーはセッションIDです)にaccess_tokenがあるかどうかと、その値がブラウザーが受け取ったレスポンスのヘッダー・本文のどこにもないかどうかを、/root/st/bff/exposure.txtに書いてください。
  4. bobでログインしたCookieでGET /bff/api/meを呼び、BFFがBearerを付けて下流で200とsub=bobを受け取ること、下流にCookieが渡らないことを/root/st/bff/proxy.txtに書いてください。
  5. 下流の/meを、トークンなし、セッションCookieのみ、でたらめなBearerで直接呼び、すべて401とWWW-Authenticate: Bearerになるかどうかを/root/st/bff/direct.txtに書いてください。
  6. POST /bff/api/notesが、正しいX-CSRF-Tokenのときだけ201になり、ないか間違っていると403になるかどうかを/root/st/bff/csrf.txtに書いてください。
  7. curl -X POSTでログアウトした後、BFFのセッションが消え、古いaccess tokenが下流で401になるかどうかを/root/st/bff/logout.txtに書いてください。
  8. /root/st/bff/e2e.shで全体の流れを試し、/root/st/bff/e2e.outに6行を残してください。

参考

発行者・下流API・BFFを起動する

/root/st/bff/app.py1つで、発行者(127.0.0.1:8312)、下流API(127.0.0.1:8311)、BFF(127.0.0.1:8310)を起動してください。3つともGET /healthは200と{"ok":true}を返します。

1つのプロセスの中でThreadingHTTPServerを3つスレッドで動かすと、起動と停止が一度に済みます。サーバーはバックグラウンドで起動し、3つのポートがすべて応答するまでポーリングしてください。

ブラウザーが受け取るのはセッションCookie1つだけ

GET /login→発行者のPOST /authorize(フォームuser=alice&pw=wonderland)→BFFの/callbackの順でログインし、コールバックが返したSet-Cookieの1行を/root/st/bff/cookie.txtに保存してください。Cookie名は__Host-Http-bffで、HttpOnly、Secure、SameSite=Strict、Path=/を持ち、Domainはありません。

curl -w '%{redirect_url}'で302のLocationを受け取り、次のリクエストに渡すと、ブラウザーのように流れをたどれます。発行者にアカウントを送るリクエストはPOSTです。RFC 10017 6.1.3.2がHttpOnlyとSecureをMUSTとしています。

トークンはBFFのセッションにだけある

ログイン後、Redisのbff:sess:<세션ID>(JSON。プレースホルダーはセッションIDです)にaccess_tokenがあり、その値がコールバックのレスポンスとGET /bff/sessionのレスポンスのヘッダー・本文のどこにもないかどうかを突き合わせ、/root/st/bff/exposure.txtにtoken_in_store=yes、token_in_body=no、token_in_cookie=noと書いてください。GET /bff/sessionはauth・user・csrfだけを返します。

セッションIDはCookie jarから、本物のトークンはredis-cli GETで取り出します。その文字列をgrep -Fでレスポンスのファイル群から探してみてください。セッション確認のレスポンスに利便性のためトークンを載せた瞬間、BFFは名前だけになります。

BFFがBearerを付けて下流を呼ぶ

bob/builderでログインしたCookieでGET /bff/api/meを呼ぶと、BFFがCookieを外してAuthorization: Bearer <토큰>(プレースホルダーはトークンです)を付け、下流のGET /meへ渡して、200と{"sub":"bob"}を受け取ります。下流のGET /inspectは、cookie_forwardedでCookieが渡ってきたかどうかを教えます。結果を/root/st/bff/proxy.txtにme=200、sub=bob、cookie_forwarded=Falseと書いてください。

BFFは/bff/api/ の後ろのパスを下流へ移し、リクエストヘッダーを丸ごとコピーせずにAuthorizationを新しく作ってください。下流がトークンなしのリクエストを401で防いでいれば、BFF経由の200は、BFFがトークンを付けた証拠になります。

下流を直接呼ぶと401

BFFを飛ばして下流のGET http://127.0.0.1:8311/meを、トークンなし、BFFのセッションCookieのみ、でたらめなBearer値で、それぞれ直接呼び、すべて401になりWWW-Authenticate: Bearerが付くかどうかを/root/st/bff/direct.txtにno_token=401、cookie_only=401、bogus_bearer=401、www_authenticate=Bearerと書いてください。トークンなしのPOST /notesも401である必要があります。

下流はCookieを見ず、Authorizationヘッダーだけを見ます。トークンが生きているかは、発行者のintrospectionに問い合わせて判断してください。RFC 6750 3節が、401のレスポンスに付けるヘッダーを定めています。

BFFの書き込みはCSRFトークンで守る

GET /bff/sessionのcsrfの値をX-CSRF-Tokenヘッダーで送ったときだけ、POST /bff/api/notes(フォームtext=...)が下流へ渡って201になり、ヘッダーがないか間違っていると403になって下流に記録されないかどうかを、/root/st/bff/csrf.txtにno_header=403、wrong=403、right=201と書いてください。

ブラウザーはCookieを自動で載せるので、Cookieだけでは偽造リクエストと区別できません。セッションにCSRFトークンを一緒に入れておき、hmac.compare_digestで比較してください。拒否は下流を呼ぶ前に行う必要があります。

ログアウトでセッションとトークンを一緒に終わらせる

ログイン後、curl -X POSTでPOST /logout(CookieとX-CSRF-Tokenを含む)を呼び、同じCookieのGET /bff/sessionがauthのFalseになること、Redisのbff:sess:<세션ID>(プレースホルダーはセッションIDです)が消えること、ログアウト前に取り出しておいたaccess tokenで下流の/meを直接呼ぶと401になることを、/root/st/bff/logout.txtにsession_auth=False、store_key_gone=yes、old_token_direct=401と書いてください。

セッションキーだけを消すと、発行者側のトークンは有効期限まで生きています。ログアウトでBFFが、発行者の取り消しエンドポイント(RFC 7009)を先に呼んでください。-X POSTを外すと、curlはGETを送ります。

全体の流れを一度に証明する

/root/st/bff/e2e.shでログインからログアウトまでを試し、/root/st/bff/e2e.outにcookie_httponly=yes、token_exposed=no、via_bff=200、direct_no_token=401、csrf_missing=403、after_logout_token=401の6行を残してください。採点ツールはe2e.shをもう一度実行し、同じ結果になるかも確認します。

前のステップを1つのスクリプトにつなげます。スクリプトは自分が作った一時ファイルを消し、結果を標準出力だけに出せば、再実行しても安全です。