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

Keycloakと企業認証

FastAPIアプリにトークン検証ミドルウェアを付ける

TT Labで続きを見る

目標

FastAPIアプリにJWT検証のミドルウェアを自分で組み込み、トークンなし・期限切れ・誤った対象・権限不足を、それぞれ正しいステータスコードで区別して処理します。

なぜ重要なのか

トークン検証のミドルウェアは、たいていライブラリで解決しますが、オプションを1つ間違えると静かに突破されます。そして、ステータスコードの区別もよく間違えます。トークンがないか、有効でなければ401で、トークンは有効なのに権限が不足していれば403です。この2つをひとまとめにすると、クライアントが「再ログインすべきか」と「権限を申請すべきか」を区別できません。ステップ3のJWKSキャッシュも、実務で重要です。リクエストのたびに認証サーバーに公開鍵を問い合わせると、ローカル検証の利点が失われます。かといって、無期限にキャッシュすると、鍵のローテーションの直後にすべての検証が失敗します。キャッシュしつつ、kidが見つからなければ1回更新するのが標準的な実装で、このラボでそれを自分で作ります。

ステップ

  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を返します。
  2. GET /meをトークンなしで呼び出すと401になり、レスポンスにWWW-Authenticateヘッダーがなければなりません。
  3. 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行ずつ保存しておいてください。
  4. 有効なトークンでGET /meを呼び出すと、200と{"sub":"<값>","username":"dev1"}を返します(プレースホルダーは値です)。
  5. 期限切れのトークンで呼び出すと401になり、本文にexpiredが含まれていなければなりません。期限切れのトークンは、/opt/fixtures/kc/expired.jwtにあります。
  6. 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を書いてください。

参考

アプリを起動して公開エンドポイントを確認する

/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つのスクリプトにまとめて、期待するステータスコードと比較します。