JWT オーソライザーで API ルートを保護する

AWSBeginner
オンラインで実践に進む

はじめに

注文 API は、ヘルスチェックを公開したまま、読み取りと書き込みにサインインを要求する必要があります。このラボでは、両方の注文ルートにトークンのチェックを付け、許可されるリクエストと拒否されるリクエストをテストして、拒否された呼び出しが業務データを変更しないことを確認します。

最初に Cognito でアプリケーションへのサインインを追加する と 注文 API を構築して検証する を完了してください。この新しい環境には、動作する公開バックエンドと架空のアイデンティティの準備用リソースがあります。以前のリソースとトークンは再利用しません。

認定試験との関連

このラボでは、次の試験トピックに関連する実践的な演習を行います。

公開ルートを確認し、サインインする

このステップでは、現在の公開ルートの状態を確認し、目的のアプリクライアントの実際のアクセストークンを取得します。

作業ディレクトリに移動し、新しく作成するトークンファイルを非公開にします。

cd /home/labex/project
umask 077

Terminal の隣の AWS View を開き、API リクエスト、関数の実行、保存された注文を比較します。ゲートウェイのリクエストログと Lambda の実行ログは別です。拒否されたトークンは、関数が実行される前に止まる必要があります。

準備用リソースの安全な一覧を読みます。目的のプールとクライアント、別のクライアント、別の発行者、無関係な参照プールを識別できます。

cat scenario.json

jq -r で主要な ID を選び、$(...) のコマンド置換で出力を保存します。

POOL_ID=$(jq -r '.main.pool' scenario.json)
CLIENT_ID=$(jq -r '.main.client' scenario.json)

名前で準備済みの HTTP API を見つけ、削除用に生成された ID を記録します。

API_ID=$(aws apigatewayv2 get-apis \
  --query "Items[?Name=='labex-a05'].ApiId | [0]" \
  --output text)
printf '%s\n' "$API_ID" > api-id.txt

ルートの認可タイプを確認します。

aws apigatewayv2 get-routes \
  --api-id "$API_ID" \
  --query 'Items[].{Route:RouteKey,Authorization:AuthorizationType}'

最初は、3 つのルートすべてが NONE を使っています。ユーザーディレクトリだけでは、API は保護されていません。作業環境のアドレスを作成します。

API_URL="http://127.0.0.1:8081/api/$API_ID"
curl -i "$API_URL/health"

HTTP 200 と healthy: true が返り、AWS View に実際のヘルスチェック実行が表示され、注文はないはずです。

目的の公開クライアントを通じて準備済みの架空のユーザーでサインインし、実際のレスポンスを非公開で保存します。

aws cognito-idp initiate-auth \
  --client-id "$CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters file://sign-in.json \
  --query AuthenticationResult \
  --output json > tokens.json

表示せずに、アクセストークンと ID トークンを取り出します。

jq -r '.AccessToken' tokens.json > access-token.txt
jq -r '.IdToken' tokens.json > id-token.txt

アクセストークンだけを使い、実際のアプリケーションのユーザー名を読み取ります。

aws cognito-idp get-user \
  --access-token "$(cat access-token.txt)" \
  --query Username \
  --output text

labex-demo が返るはずです。AWS View には、準備済みの確認済みユーザーとクライアントが表示されます。独立したチェックは、実際に発行されたトークンと実際のアイデンティティを検証します。代わりに再認証することはありません。

両方の注文ルートでオーソライザーを必須にする

このステップでは、目的の発行者と対象クライアントを設定し、非公開の読み取りと書き込みに同じ JWT オーソライザーを適用します。

JWT オーソライザーは、API Gateway が保護されたルートを呼び出す前に、署名付きトークンを確認します。発行者は、署名された Cognito プールのアイデンティティです。対象クライアント(audience)は、目的のアプリクライアントです。API Gateway は、aud があればそれを検証し、なければ client_id を検証します。実際のプール ID から発行者を作成します。

ISSUER="https://cognito-idp.us-east-1.amazonaws.com/$POOL_ID"

引用符のないヒアドキュメントのマーカーを使い、シェルが 2 つの ID を通常の設定ファイルに展開できるようにします。

cat > jwt-config.json <<JSON
{
  "Issuer": "$ISSUER",
  "Audience": ["$CLIENT_ID"]
}
JSON

JWT オーソライザーを作成します。一重引用符により、アイデンティティの取得元である $request.header.Authorization を、シェル変数として展開せず、そのまま保持します。

AUTH_ID=$(aws apigatewayv2 create-authorizer \
  --api-id "$API_ID" \
  --name OrdersUsers \
  --authorizer-type JWT \
  --identity-source '$request.header.Authorization' \
  --jwt-configuration file://jwt-config.json \
  --query AuthorizerId \
  --output text)

各注文ルートの生成された ID を見つけます。

READ_ROUTE_ID=$(aws apigatewayv2 get-routes \
  --api-id "$API_ID" \
  --query "Items[?RouteKey=='GET /orders'].RouteId | [0]" \
  --output text)
WRITE_ROUTE_ID=$(aws apigatewayv2 get-routes \
  --api-id "$API_ID" \
  --query "Items[?RouteKey=='POST /orders'].RouteId | [0]" \
  --output text)

API Gateway は、あらゆる場合にアクセストークンと ID トークンを区別するわけではありません。これらの Cognito のパスワードフローのアクセストークンには、aws.cognito.signin.user.admin が含まれます。そのスコープを必須にすると、それを持たない ID トークンを拒否できます。このスコープは、Cognito のユーザーセルフサービスへのアクセスを示し、AWS の管理や注文の所有権を示すものではありません。この演習では、独自の業務スコープは作成しません。

読み取りルートで JWT とそのスコープを必須にします。

aws apigatewayv2 update-route \
  --api-id "$API_ID" \
  --route-id "$READ_ROUTE_ID" \
  --authorization-type JWT \
  --authorizer-id "$AUTH_ID" \
  --authorization-scopes aws.cognito.signin.user.admin \
  --query '{Route:RouteKey,Authorization:AuthorizationType,Scopes:AuthorizationScopes}'

書き込みルートにも、同じ要件を適用します。

aws apigatewayv2 update-route \
  --api-id "$API_ID" \
  --route-id "$WRITE_ROUTE_ID" \
  --authorization-type JWT \
  --authorizer-id "$AUTH_ID" \
  --authorization-scopes aws.cognito.signin.user.admin \
  --query '{Route:RouteKey,Authorization:AuthorizationType,Scopes:AuthorizationScopes}'

準備済みの $default ステージは AutoDeploy を使うため、ルートの変更は自動的に適用されます。すべてのルートタイプをもう一度読み取ります。

aws apigatewayv2 get-routes \
  --api-id "$API_ID" \
  --query 'Items[].{Route:RouteKey,Authorization:AuthorizationType}'

両方の注文ルートは JWT、GET /health は NONE になるはずです。AWS View で、実際の発行者、目的の対象クライアント、ルートのオーソライザー ID、必須スコープを比較してください。オーソライザーを作成するだけで、各非公開ルートに付けなければ、それらのルートは保護されません。

公開ヘルスチェックと JWT で保護された注文ルートを示す AWS View の例

この例では、2 つの注文ルートが目的のオーソライザーとスコープを共有し、ヘルスチェックは公開のままです。生成されたリソース ID は、自分の VM では異なります。

ハンドラーが実行される前に、オーソライザーが非公開のリクエストを許可または拒否します。

許可されるリクエストと拒否されるリクエストをテストする

このステップでは、目的のユーザーによる実際の業務アクセスと、トークンがない場合や不適切な場合の Lambda 実行前の拒否を確かめます。

Bearer トークンは、それを提示する人を認証します。Authorization ヘッダー内で、非公開の受け渡し用ファイルから読み取ります。curl -i を使い続け、レスポンスのヘッダーと本文だけを表示してください。リクエストヘッダーを詳細に記録する機能は使わないでください。数量 4 の有効な注文を送ります。

curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat access-token.txt)" -H 'Content-Type: application/json' --data '{"id":"signed-order","quantity":4}'

HTTP 201 と合計 1100(4 × 250 + 100)が返るはずです。アクセストークンで、同じ保存された注文を取得します。

curl -i "$API_URL/orders?id=signed-order" -H "Authorization: Bearer $(cat access-token.txt)"

HTTP 200 と、同じ ID、数量、計算済みの合計が返るはずです。サービスのアイテムを独立して確認します。

aws dynamodb get-item \
  --table-name labex-a05-orders \
  --key '{"id":{"S":"signed-order"}}' \
  --consistent-read \
  --query Item

次に、トークンなしで読み取りを繰り返します。

curl -i "$API_URL/orders?id=signed-order"

HTTP 401 Unauthorized が返り、レスポンスに非公開レコードは含まれないはずです。匿名の書き込みもテストします。

curl -i -X POST "$API_URL/orders" -H 'Content-Type: application/json' --data '{"id":"reject-missing","quantity":9}'

これも 401 を返し、アイテムを作成してはいけません。非公開の読み取りと書き込みの両方に、認可が必要です。

提供された不正署名のテストデータは、署名の 1 バイトが変更されています。見えるクレームのデータがもっともらしくても、信頼できません。

curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat bad-signature-token.txt)" -H 'Content-Type: application/json' --data '{"id":"reject-signature","quantity":9}'

401 が返るはずです。別のアプリクライアントに対して本当に発行されたトークンも、この API には不適切です。その安全なクライアント ID を読み、そのクライアントを通じてサインインして、アクセストークンを非公開で取り出します。

WRONG_CLIENT_ID=$(jq -r '.wrong_client' scenario.json)
aws cognito-idp initiate-auth \
  --client-id "$WRONG_CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters file://sign-in.json \
  --query AuthenticationResult \
  --output json > wrong-client.json
jq -r '.AccessToken' wrong-client.json > wrong-client-token.txt
curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat wrong-client-token.txt)" -H 'Content-Type: application/json' --data '{"id":"reject-client","quantity":9}'

401 が返るはずです。有効な署名だけでは、設定された対象クライアントに一致しません。次に、独自の架空のユーザーと公開クライアントを持つ、準備済みの別の Cognito 発行者を使います。

OTHER_POOL_ID=$(jq -r '.other.pool' scenario.json)
OTHER_CLIENT_ID=$(jq -r '.other.client' scenario.json)
aws cognito-idp initiate-auth \
  --client-id "$OTHER_CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters file://sign-in.json \
  --query AuthenticationResult \
  --output json > wrong-issuer.json
jq -r '.AccessToken' wrong-issuer.json > wrong-issuer-token.txt
curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat wrong-issuer-token.txt)" -H 'Content-Type: application/json' --data '{"id":"reject-issuer","quantity":9}'

署名された発行者が期待するプールと異なるため、401 が返るはずです。提供された期限切れのテストデータは、すでに過去になった exp で署名されています。実際のセッションが期限切れになるのを待たずに、時刻のルールをテストできます。

curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat expired-token.txt)" -H 'Content-Type: application/json' --data '{"id":"reject-expired","quantity":9}'

401 が返るはずです。ID トークンは、このクライアントに対して署名されていますが、ルートが要求するアクセストークンのスコープを持っていません。

curl -i -X POST "$API_URL/orders" -H "Authorization: Bearer $(cat id-token.txt)" -H 'Content-Type: application/json' --data '{"id":"reject-id-token","quantity":9}'

スコープ不足によって、403 が返るはずです。署名、発行者、対象クライアント、トークンの有効期間は必要ですが、不足する権限を与えるものではありません。

ヘルスチェックが公開のままで、有効な業務の書き込みだけが保存されたことを確認します。

curl -i "$API_URL/health"
aws dynamodb scan --table-name labex-a05-orders --query Items

ヘルスチェックは 200、注文は合計 1100 の signed-order だけになるはずです。AWS View で、実際の API requests と CloudWatch Logs を比較します。認可の拒否には、ゲートウェイの 401 または 403 の結果があり、対応する関数の実行はありません。成功した非公開リクエストには、実際の Lambda イベントとレスポンスがあり、認証情報のヘッダーは除外されています。評価の証拠は、これらのランタイムと API の結果、およびサービス内のデータです。スクリーンショットやローカルの成功マーカーではありません。

実際の許可された API リクエストと拒否されたリクエストの例

この例では、有効な注文の書き込みと読み取りは 201 と 200 を返し、不適切なトークンは 401 または 403 を返しました。関数の実行一覧とサービスのテーブルによって、拒否されたリクエストが業務の書き込みを行わなかったことを独立して確認できます。

保護したルートと所有するリソースを削除する

このステップでは、無関係な参照データだけを保持し、所有する API、アイデンティティの準備用リソース、データを削除します。

リソース一覧は、この API とそのオーソライザー、関数とロググループ、ゲートウェイのリクエストロググループ、注文テーブル、ロールの OrdersData ポリシー、主要プール(クライアント 2 つとユーザー 1 人)、別の発行者のプール(クライアント 1 つとユーザー 1 人)、非公開の入力とレスポンスのファイルです。参照プール、テーブル、参照ログ、ロールの FunctionLogs ポリシーは無関係なので、残す必要があります。

オーソライザー、ルート、統合、ステージを含めて API を削除します。

aws apigatewayv2 delete-api --api-id "$API_ID"
aws lambda delete-function --function-name labex-a05-orders-api
aws logs delete-log-group --log-group-name /aws/lambda/labex-a05-orders-api
aws logs delete-log-group --log-group-name /aws/apigateway/labex-a05
aws dynamodb delete-table \
  --table-name labex-a05-orders \
  --query TableDescription.TableName \
  --output text
aws iam delete-role-policy --role-name labex-a05-execution --policy-name OrdersData

架空のユーザーと、所有する各アプリクライアントを削除してから、それぞれのプールを削除します。

aws cognito-idp admin-delete-user --user-pool-id "$POOL_ID" --username labex-demo
aws cognito-idp delete-user-pool-client --user-pool-id "$POOL_ID" --client-id "$CLIENT_ID"
aws cognito-idp delete-user-pool-client \
  --user-pool-id "$POOL_ID" \
  --client-id "$WRONG_CLIENT_ID"
aws cognito-idp delete-user-pool --user-pool-id "$POOL_ID"
aws cognito-idp admin-delete-user --user-pool-id "$OTHER_POOL_ID" --username labex-demo
aws cognito-idp delete-user-pool-client \
  --user-pool-id "$OTHER_POOL_ID" \
  --client-id "$OTHER_CLIENT_ID"
aws cognito-idp delete-user-pool --user-pool-id "$OTHER_POOL_ID"

不在を証明するため、正常に一覧をクエリします。ネットワークや認証のエラーは、削除の証拠にはなりません。

aws apigatewayv2 get-apis --query 'Items[].Name'
aws lambda list-functions --query 'Functions[].FunctionName'
aws cognito-idp list-user-pools --max-results 60 --query 'UserPools[].Name'
aws dynamodb list-tables --query TableNames

API と関数のリストは空、プールとテーブルのリストには labex-a05-reference だけが残り、AWS View に参照データとログが保持されるはずです。列挙した非公開ファイルだけを削除します。

rm -f tokens.json access-token.txt id-token.txt wrong-client.json wrong-client-token.txt wrong-issuer.json wrong-issuer-token.txt expired-token.txt future-iat-token.txt future-nbf-token.txt bad-signature-token.txt sign-in.json

ローカルファイルの削除だけでは、一般的なサーバー側の JWT の失効にはなりません。所有する架空のユーザー、クライアント、プール、API も削除しました。参照リソース、最終的な認証情報の失効、VM の終了は、これらの読み取り専用のリソース確認の後に、制作者が行う後片付けとして残ります。

まとめ

Cognito の JWT オーソライザーを設定し、公開ヘルスチェックを保持しながら、非公開の読み取りと書き込みで必須にしました。実際に許可されるリクエストと、トークンなし、改ざん、別のクライアント、別の発行者、期限切れ、ID トークンによる拒否を試しました。実際のゲートウェイの結果を、サービスの Lambda 実行と保存データに対応付け、最後に、参照データを保持しながら、所有する API、アイデンティティ、データのリソースと非公開ファイルを削除しました。JWT の認可は、注文ごとの所有権を実装するものではありませんでした。