FastAPIアプリにトークン検証ミドルウェアを付ける
目標
FastAPIアプリにJWT検証のミドルウェアを自分で組み込み、トークンなし・期限切れ・誤った対象・権限不足を、それぞれ正しいステータスコードで区別して処理します。
なぜ重要なのか
トークン検証のミドルウェアは、たいていライブラリで解決しますが、オプションを1つ間違えると静かに突破されます。そして、ステータスコードの区別もよく間違えます。トークンがないか、有効でなければ401で、トークンは有効なのに権限が不足していれば403です。この2つをひとまとめにすると、クライアントが「再ログインすべきか」と「権限を申請すべきか」を区別できません。ステップ3のJWKSキャッシュも、実務で重要です。リクエストのたびに認証サーバーに公開鍵を問い合わせると、ローカル検証の利点が失われます。かといって、無期限にキャッシュすると、鍵のローテーションの直後にすべての検証が失敗します。キャッシュしつつ、kidが見つからなければ1回更新するのが標準的な実装で、このラボでそれを自分で作ります。
ステップ
/root/app/main.pyを127.0.0.1:8160で起動してください。検証の対象は、事前にインポートされたレルムlabhubで、期待するaudはlab-apiです。ユーザーはdev1(order-reader)とadmin1(order-admin)で、パスワードは/opt/fixtures/kc/realm-info.envにあります。GET /publicが認証なしで200を返します。GET /meをトークンなしで呼び出すと401になり、レスポンスにWWW-Authenticateヘッダーがなければなりません。- JWKSをメモリにキャッシュしてください。
GET /_debug/jwksが{"cached":true,"keys":<n>,"fetches":<n>}を返し、/meを5回呼び出した後でもfetchesが2以下でなければなりません。このステップから、dev1のアクセストークンを/root/app/token.txtに、admin1のものを/root/app/admin_token.txtに、1行ずつ保存しておいてください。 - 有効なトークンで
GET /meを呼び出すと、200と{"sub":"<값>","username":"dev1"}を返します(プレースホルダーは値です)。 - 期限切れのトークンで呼び出すと401になり、本文に
expiredが含まれていなければなりません。期限切れのトークンは、/opt/fixtures/kc/expired.jwtにあります。 audが異なるトークンで呼び出すと401になり、本文にaudienceが含まれていなければなりません。そのようなトークンは、/opt/fixtures/kc/wrongaud.jwtにあります。
ステップ5・6の失敗ケースのトークンは、Podが起動するときに/opt/fixtures/kc/gen-tokens.pyが作ります。2つのファイルが見えなければ、そのスクリプトを自分で一度実行してください。フィクスチャのディレクトリが読み取り専用なら、/tmp/lab-kc/の下に作られます。
7. GET /adminは、order-adminロールがあれば200、なければ403です。401ではなく403でなければなりません。
8. /root/app/e2e.shで5つのケース(トークンなし、有効、期限切れ、誤ったaud、権限不足)を順に試して、/root/app/e2e.outにno_token=401 valid=200 expired=401 bad_aud=401 forbidden=403を書いてください。
参考
- 401は「誰かわからない」、403は「誰かはわかるが、権限がない」です。
- JWKSのキャッシュは、TTLを設けつつ、
kidのミスのときにはすぐに更新するのが標準です。 - 時計のずれの許容(leeway)は、数秒以内とごく小さくします。
- よくあるミス1: 権限不足に401を返すことです。クライアントが不必要に再ログインします。
- よくあるミス2: 検証ライブラリに、期待する
issuerとaudienceを渡さないことです。署名さえ合っていれば通ってしまいます。
アプリを起動して公開エンドポイントを確認する
/root/app/main.pyを127.0.0.1:8160で起動してください。検証の対象は、事前にインポートされたレルムlabhubで、期待するaudはlab-apiです。ユーザーはdev1(order-reader)とadmin1(order-admin)で、パスワードは/opt/fixtures/kc/realm-info.envにあります。GET /publicが認証なしで200を返します。
認証が不要なパスも1つなければ、ヘルスチェックができません。
トークンがなければ401を返す
GET /meをトークンなしで呼び出すと401になり、レスポンスにWWW-Authenticateヘッダーがなければなりません。
401には、どんな認証が必要かを知らせるヘッダーを一緒に付ける必要があります。
JWKSのキャッシュを実装する
JWKSをメモリにキャッシュしてください。GET /_debug/jwksが{"cached":true,"keys":<n>,"fetches":<n>}を返し、/meを5回呼び出した後でもfetchesが2以下でなければなりません。このステップから、dev1のアクセストークンを/root/app/token.txtに、admin1のものを/root/app/admin_token.txtに、1行ずつ保存しておいてください。
リクエストのたびに認証サーバーに問い合わせると、それがボトルネックになります。キャッシュしつつ、鍵が見つからなければ更新してください。
有効なトークンを通す
有効なトークンでGET /meを呼び出すと、200と{"sub":"<값>","username":"dev1"}を返します(プレースホルダーは値です)。
署名の検証とクレームの検査を、両方行う必要があります。レスポンスに、主体の識別子を入れてください。
期限切れのトークンを拒否する
期限切れのトークンで呼び出すと401になり、本文にexpiredが含まれていなければなりません。期限切れのトークンは、/opt/fixtures/kc/expired.jwtにあります。
有効期限の時刻を過ぎたトークンで試します。時計のずれのための余裕は、ごく小さくしてください。
誤った対象のトークンを拒否する
audが異なるトークンで呼び出すと401になり、本文にaudienceが含まれていなければなりません。そのようなトークンは、/opt/fixtures/kc/wrongaud.jwtにあります。
同じサーバーが発行したものでも、私たちのAPI向けでなければ、拒否しなければなりません。
ロールに基づく認可を付ける
GET /adminは、order-adminロールがあれば200、なければ403です。401ではなく403でなければなりません。
認証は入口で、認可はリソースの地点で行います。権限がなければ、401ではなく403です。
5つのケースを一度に検証する
/root/app/e2e.shで5つのケース(トークンなし、有効、期限切れ、誤ったaud、権限不足)を順に試して、/root/app/e2e.outにno_token=401 valid=200 expired=401 bad_aud=401 forbidden=403を書いてください。
前のケースを1つのスクリプトにまとめて、期待するステータスコードと比較します。