リフレッシュトークンのローテーションと再利用検知
目標
Redisにトークンファミリーを保存する小さな発行者を立て、refreshを使うたびにローテーションするか、使用済みのrefreshが再び届いたらそのファミリー全体が取り消されるか、accessが短命か、ログアウトがファミリーを断ち切るかを、実際のHTTPリクエストで確認します。
なぜ重要なのか
accessを短命にすれば、漏れても被害を受ける期間は短くなりますが、その代償として、長く生きるrefreshが新しい標的になります。秘密を隠せないブラウザーやモバイルアプリに対して、RFC 9700はrefreshをクライアントの鍵に結び付けるか、ローテーションを使うよう求めています。ローテーションの力は再利用検知から生まれます。トークンが漏れて攻撃者と正常なクライアントの両方が使うと、どちらかは必ず使用済みのトークンを出すことになり、サーバーは誰が攻撃者かわからないので、そのファミリー全体を断ち切ります。これが機能するには、使ったrefreshを消さずに「使用済み」として残しておく必要があり、その印はアトミックに付ける必要があります。このラボは、それらのルールを自分で組み、自分で証明させます。
ステップ
/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)のハッシュで行います(プレースホルダーはトークンです)。POST /login(formuser=alice&pw=wonderland)のレスポンス本文を/root/st/refresh/login.jsonに保存してください。そのaccessでGET /api/meは200を返し、トークンがない場合や作り物のトークンの場合、間違ったパスワードでのログインは401です。- 新しくログインして受け取ったrefreshで、
POST /token(formgrant_type=refresh_token&refresh_token=<값>)を送り(プレースホルダーは値です)、レスポンスを/root/st/refresh/refresh.jsonに保存してください。 - ログイン→refresh(RT1)→新しいRT2でrefresh→古いRT1を再提出、の順で試し、
/root/st/refresh/rotate.txtにfirst_status= new_differs= new_status= old_status=を書いてください。 bob/builderでログインして1回ローテーションした後、古いRT1をもう一度出し、最新のRT2とAT2を使ってみて、/root/st/refresh/reuse.txtにreplay_status= active_after= access_after= family_state=を書いてください。- ログインのレスポンスの
expires_in(1–300秒)と、Redisのaccess・refreshのTTLを、/root/st/refresh/ttl.txtにexpires_in= access_ttl= refresh_ttl=の形で書いてください。期限切れのaccessは401である必要があります。 POST /revoke(formtoken=<refresh>)でログアウトした後、同じrefreshとaccessを使ってみて、/root/st/refresh/revoke.txtにrevoke_status= refresh_after= access_after= family_state=を書いてください。/root/st/refresh/e2e.shでローテーション・再利用検知・ファミリーの取り消しを試し、/root/st/refresh/e2e.outにrotate=ok、reuse_detected=yes、family_revoked=yesを残してください。
参考
- POSTは
curl -X POST --data-urlencode ...で明示します。トークンの値はpython3 -cでJSONから取り出します。 - 無効・期限切れ・取り消し済みのrefreshに対しては、トークンエンドポイントは400
invalid_grant(RFC 6749 5.2)、保護されたAPIは401invalid_token(RFC 6750 3.1)で答えます。 - 「使用済み」の印は、
HINCRBY rt:<토큰> uses 1の1コマンドで付けます(プレースホルダーはトークンです)。読み取ってから別に書き込むと、同時に来た2つのリクエストがどちらも通ります。 - よくある失敗1: 使ったrefreshをすぐ消してしまうケースです。再び届いたときに見たことのない値と区別がつかず、再利用を検知できません。
- よくある失敗2: 再利用されたトークンだけを拒否してファミリーを生かしておくケースです。攻撃者が先にローテーションしていた場合、攻撃者の手にある最新のトークンが使い続けられます。
トークン発行者を起動する
/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です。