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

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

リフレッシュトークンのローテーションと再利用検知

TT Labで続きを見る

目標

Redisにトークンファミリーを保存する小さな発行者を立て、refreshを使うたびにローテーションするか、使用済みのrefreshが再び届いたらそのファミリー全体が取り消されるか、accessが短命か、ログアウトがファミリーを断ち切るかを、実際のHTTPリクエストで確認します。

なぜ重要なのか

accessを短命にすれば、漏れても被害を受ける期間は短くなりますが、その代償として、長く生きるrefreshが新しい標的になります。秘密を隠せないブラウザーやモバイルアプリに対して、RFC 9700はrefreshをクライアントの鍵に結び付けるか、ローテーションを使うよう求めています。ローテーションの力は再利用検知から生まれます。トークンが漏れて攻撃者と正常なクライアントの両方が使うと、どちらかは必ず使用済みのトークンを出すことになり、サーバーは誰が攻撃者かわからないので、そのファミリー全体を断ち切ります。これが機能するには、使ったrefreshを消さずに「使用済み」として残しておく必要があり、その印はアトミックに付ける必要があります。このラボは、それらのルールを自分で組み、自分で証明させます。

ステップ

  1. /root/st/refresh/app.pyを127.0.0.1:8306で起動してください。GET /healthは200と{"ok":true}を返します。保存はRedisに、fam:<id>(user, state, current)、rt:<토큰>(user, fam, uses)、at:<토큰>(user, fam)のハッシュで行います(プレースホルダーはトークンです)。
  2. POST /login(formuser=alice&pw=wonderland)のレスポンス本文を/root/st/refresh/login.jsonに保存してください。そのaccessでGET /api/meは200を返し、トークンがない場合や作り物のトークンの場合、間違ったパスワードでのログインは401です。
  3. 新しくログインして受け取ったrefreshで、POST /token(formgrant_type=refresh_token&refresh_token=<값>)を送り(プレースホルダーは値です)、レスポンスを/root/st/refresh/refresh.jsonに保存してください。
  4. ログイン→refresh(RT1)→新しいRT2でrefresh→古いRT1を再提出、の順で試し、/root/st/refresh/rotate.txtにfirst_status= new_differs= new_status= old_status=を書いてください。
  5. bob/builderでログインして1回ローテーションした後、古いRT1をもう一度出し、最新のRT2とAT2を使ってみて、/root/st/refresh/reuse.txtにreplay_status= active_after= access_after= family_state=を書いてください。
  6. ログインのレスポンスのexpires_in(1–300秒)と、Redisのaccess・refreshのTTLを、/root/st/refresh/ttl.txtにexpires_in= access_ttl= refresh_ttl=の形で書いてください。期限切れのaccessは401である必要があります。
  7. POST /revoke(formtoken=<refresh>)でログアウトした後、同じrefreshとaccessを使ってみて、/root/st/refresh/revoke.txtにrevoke_status= refresh_after= access_after= family_state=を書いてください。
  8. /root/st/refresh/e2e.shでローテーション・再利用検知・ファミリーの取り消しを試し、/root/st/refresh/e2e.outにrotate=ok、reuse_detected=yes、family_revoked=yesを残してください。

参考

トークン発行者を起動する

/root/st/refresh/app.pyを127.0.0.1:8306で起動してください。GET /healthは200と{"ok":true}を返します。保存はRedis(127.0.0.1:6379)に、fam:<id>(user, state, current)、rt:<토큰>(user, fam, uses)、at:<토큰>(user, fam)のハッシュで行います(プレースホルダーはトークンです)。

Redisはすでに6379で動いています。サーバーはバックグラウンドで起動し、/healthが応答するまでポーリングしてください。python-multipartがないので、form本文はurllib.parse.parse_qsで直接解析します。

ログインでトークンの組を受け取る

POST /login(formuser=alice&pw=wonderland)のレスポンス本文を/root/st/refresh/login.jsonにそのまま保存してください。本文にはaccess_token、refresh_token、token_type(Bearer)、expires_inが必要で、そのaccessでGET /api/meが200とuser=aliceを返し、トークンがない場合や作り物のトークンの場合は401、間違ったパスワードでのログインも401です。

curlに-X POSTを明示し、--data-urlencodeでformを送ります。保護されたAPIはAuthorization: Bearer ヘッダーで呼びます。パスワードグラントはRFC 9700が禁止しているので、最初のトークンは/tokenではなく/loginが渡します。

refreshで新しいaccessを受け取る

新しくログインして受け取ったrefreshで、POST /token(formgrant_type=refresh_token&refresh_token=<값>)を送り(プレースホルダーは値です)、200のレスポンス本文を/root/st/refresh/refresh.jsonに保存してください。新しいaccess_tokenはログイン時と異なり、GET /api/meで200である必要があります。

RFC 6749 6節のrefreshグラントです。本文はform-urlencodedです。login.jsonのrefreshをもう一度使うと、すでに使用済みのトークンかもしれないので、新しくログインして受け取ってください。

ローテーション: 新しいrefreshは通り、古いものは拒否する

ログイン→refresh(RT1)→レスポンスの新しいrefresh(RT2)で再びrefresh→古いRT1をもう1回提出、の順で試し、/root/st/refresh/rotate.txtにfirst_status=200、new_differs=yes、new_status=200、old_status=<400 또는 401>(プレースホルダーは400または401です)と書いてください。

refreshを使うたびに新しいrefreshを渡し、使ったrefreshは消さずにusesを増やして印だけを付けておきます。同じ値を返したり、古いものを受け付け続けたりするのはローテーションではありません。

再利用検知: ファミリー全体を取り消す

bob/builderでログインしてRT1で1回ローテーション(RT2、AT2を受け取る)した後、古いRT1をもう一度提出し、続いてRT2でrefresh、AT2でGET /api/meを試して、/root/st/refresh/reuse.txtにreplay_status=<400|401>、active_after=<400|401>、access_after=401、family_state=revokedと書いてください(family_stateはredis-cli HGET fam:<계열> stateで確認します。プレースホルダーはファミリーのIDです)。

サーバーには、古いRT1を誰が出したのかわかりません。そのためそのファミリーの現在のrefreshとaccessまですべて無効にします。/api/me は、accessのキーだけでなくファミリーの状態も見るようにしてください。

accessは短く、refreshは長く

ログインのレスポンスのexpires_inと、RedisのTTL at:<access>、TTL rt:<refresh>を、/root/st/refresh/ttl.txtにexpires_in= access_ttl= refresh_ttl=の形で書いてください。expires_inは1–300秒、access_ttlは正の数でexpires_in以下、refresh_ttlはaccess_ttlより大きい必要があります。Redisで期限切れになった(キーが消えた)accessは、GET /api/meで401である必要があります。

accessの寿命をRedisキーのTTLで表せば、期限切れは自然に起こります。refreshのTTLは、非アクティブでの期限切れ(RFC 9700 4.14.2)の役割を果たします。

ログアウトでファミリーを取り消す

ログインした後、POST /revoke(formtoken=<refresh>)でログアウトし、同じrefreshとaccessをもう一度使ってみて、/root/st/refresh/revoke.txtにrevoke_status=200、refresh_after=<400|401>、access_after=401、family_state=revokedと書いてください。知らないトークンで/revokeしても200である必要があります。

RFC 7009の形です。refreshを取り消したら、同じ承認から出たaccessも無効にすることが推奨されており、知らないトークンにも200を返します。refreshのキーを1つ消すだけでは、accessが使われ続けます。

ローテーション・再利用・取り消しを一度に証明する

/root/st/refresh/e2e.shが、ログイン→ローテーション→古いrefreshの再提出→最新のrefreshとaccessの再利用、を順に試して、rotate=ok、reuse_detected=yes、family_revoked=yesの3行を出力するようにし、その出力を/root/st/refresh/e2e.outに保存してください。採点ツールはe2e.shをもう一度実行して、同じ結果になるかも確認します。

前のステップを1つのスクリプトにつなげます。毎回新しくログインすれば、何回実行しても同じ結果になります。family_revokedは、最新のrefreshの拒否とaccessの401が両方成り立つときだけyesです。