前の記事ではCloudFrontの配信コンテンツやAPI Gatewayに対して、Cognitoの認証やCloudFrontのアクセス制限を実装しました。
Cognitoでログインしない限り、ログインページ以外の配信コンテンツを見られず、API GatewayのAPIも叩けないことを確認しましたが、その仕組みがよく分からない…
そこで今回は、ネットワークの挙動を確認して仕組みを理解したいと思います。
1. 非認証時のコンテンツやAPIの挙動
CloudFrontで配信しているコンテンツへのアクセスは、ディストリビューションドメイン名を使って、https://{ドメイン名}/~でアクセスできます。
ただし、今回の構成ではpublicとして公開しているコンテンツ(public/login.html)以外はアクセスを制限しているので、その挙動を確認します。
ディベロッパーツールやcurlコマンドでアクセスを確認すると、S3をオリジンとした/index.htmlは403 Forbidden、APIをオリジンとした/api/pingは401 Unauthorizedとなり、それぞれ以下のようなメッセージがCloudFrontから返されます。
# /*へのアクセス
<Error>
<Code>MissingKey</Code>
<Message>Missing Key-Pair-Id query parameter or cookie value</Message>
</Error>
# /api/pingへのアクセス
{"message":"Unauthorized"}
また、もう一つのAPIである/auth/set-cookieはアクセス制限を付けていませんが、curlコマンドで確認すると以下の通り。API自体は叩けますが、ヘッダーに認証情報がないため、Lambda関数の方で401を返しています。
curl -i -X POST https://***.execute-api.ap-northeast-1.amazonaws.com/prod/auth/set-cookie
# 出力
# HTTP/2 401
# 途中省略
# {"message": "Missing Authorization: Bearer <JWT>"}
401を返したLambda関数の箇所は以下の部分。
def lambda_handler(event, context):
"""CognitoのJWTを受け取り、CloudFrontのSigned Cookieを返す"""
# ヘッダー確認
headers = event.get("headers") or {}
auth = headers.get("Authorization") or headers.get("authorization")
if not auth or not auth.lower().startswith("bearer "):
return _response(401, {"message": "Missing Authorization: Bearer <JWT>"})
# 以下省略...
もちろん、publicで公開しているログインページ (/public/login.html)については問題なくアクセスでき、S3からコンテンツが取得していることが確認できます。
2. 認証からコンテンツ取得までの挙動
ログインページのボタンを押すと、CognitoのHosted UIのページに遷移。設定したユーザー名とパスワードでログインすれば、アクセス制限をしていたコンテンツにたどり着きます。この部分の裏側の挙動を確認します。
2-1. CognitoのHosted UI取得
まず、Cognitoのログイン画面 (Hosted UI)に行きます。/public/login.htmlはAIに実装してもらったんですが、PKCE (Proof Key for Code Exchange)を使った認可コード横取り攻撃の対策も行われていました。
裏側の挙動を確認します。ログインページの「Login with Cognito」ボタンを押すと、最初にCognitoの認可エンドポイントに行きます。
GET https:/***.amazoncognito.com/oauth2/authorize?...
クエリパラメータには、以下のような項目がありました。
response_type=code
client_id=***
redirect_uri=https://***.cloudfront.net/public/callback.html
scope=openid
state=...
code_challenge=...
code_challenge_method=S256
code_challengeがPKCEに関するもので、/public/login.html内のJavaScriptで生成されています。
async function startLogin() {
const state = randomString(32);
const codeVerifier = randomString(64);
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
PKCE (ピクシー)は「認可コードの横取り」を防ぐための方法。
毎回ランダムなcode_verifierを作成し、そのハッシュ値であるcode_challenge を認可サーバーへ送付。
トークン取得時にcode_verifierを提示し、サーバー側で照合が成功した場合のみアクセストークンを発行する。
この認可エンドポイントのレスポンスは302となり、レスポンスヘッダーのLocationで指示されたHosted UIのURLに遷移します。
GET https://***.amazoncognito.com/login?..
2-2. ユーザー名・パスワードをCognitoに送信
Hosted UIの画面に遷移したので、次はCognitoにユーザー情報を送ります。
POST https://***.amazoncognito.com/login
ディベロッパーツールではNetworkタブのPayload部分に、Form Dataとしてありました。
username=...
password=...
csrf=... # Cognito Hosted UIのCSRFトークン
cognitoAsfData=...
csrfはCSRF (Cross-Site Request Forgery)攻撃を防ぐためのトークン。
Cognito Hosted UI のログインフォームが正規に表示され、そのフォームから送信されたリクエストかを確認するために使用。
Hosted UIがフォーム内に埋め込み、送信時にCognito 側で検証。
(Cognito内部の保護用のトークンのようなもの?)
2-3. callback.htmlに認可コード付きで戻る
2-2でログインが成功すれば、認可コードがcallback.htmlのクエリとしてCognitoから渡されます。
GET https://***.cloudfront.net/public/callback.html?code=...&state=...
code=...がCognitoの発行した認可コード、state=...はログイン開始時にアプリが生成したCSRF/リプレイ防止用の値。(2-1のクエリパラメータ中のstateと一致)
この認可コードは短時間のみ有効で、この後のトークン取得に使います。
2-4. 認可コードをトークンに交換
callback.htmlのJavaScriptを通じて、CognitoからJWTを取得(認可コードと交換)します。
POST https://***.amazoncognito.com/oauth2/token
2-2と同様、Form Dataの部分に取得した認可コードはありました。
grant_type=authorization_code
client_id=***
code={2-3で取得した認可コード}
redirect_uri=https://***.cloudfront.net/public/callback.html
code_verifier=***
認可コードと併せて、code_verifierも送られています。
これが2-1で認可エンドポイント(/oauth2/authorize)へ送ったcode_challengeに対応する値です。
レスポンスはapplication/json;charset=UTF-8で返ってきており、ApplicationタブのSessionStorageにJWTが入っていることが確認できます。
(これ以降はJavaScriptを通じて、SessionStorageからJWTを取得しています)
2-5. /auth/set-cookieを呼び出し
次にcallback.htmlから、APIを呼んでいます。
POST https://***.cloudfront.net/auth/set-cookie
非認証時とは異なり、今回は認証情報を持っているため、以下3種類のCookieがLambda関数の出力として返ってきます。
CloudFront-Policy=...
CloudFront-Signature=...
CloudFront-Key-Pair-Id=...
この3つのCookieがCloudFrontの署名付きCookieとなり、CloudFrontでアクセス制限しているコンテンツへアクセスする際に使用されます。
2-6. トップページに遷移
問題なく/auth/set-cookieの呼び出しも終わり、署名付きCookieも取得できたので、callback.html内にあるlocation.href = "/";が実行され、index.htmlへ遷移しています。
GET https://***.cloudfront.net/
index.htmlから叩けるようにしていたAPI (/api/ping)も、以下のようにSessionStorageから認証情報を取り出してヘッダーに付けて実行するため、非認証時とは異なり200で返ってきました。
(リクエストのヘッダーには署名付きCookieも入っています)
const idToken = sessionStorage.getItem("id_token");
const res = await fetch("/api/ping", {
method: "GET",
headers: {
"Authorization": `Bearer ${idToken}`
},
credentials: "include",
cache: "no-store"
});
まとめ
今回の構成では以下のようにして認証フローが進んでいました。
-
/public/login.htmlを表示 - JavaScriptが
stateとPKCEのcode_verifier/code_challengeを生成 - ログインのボタンからCognitoの認可エンドポイントへ遷移
- CognitoのHosted UIにリダイレクト
- Hosted UIでユーザー名とパスワードをPOST
- Cognitoが認可コードを
callback.htmlに返す -
callback.htmlが認可エンドポイントに認可コードとcode_verifierを送る - CognitoがJWTを返す
-
callback.htmlが/auth/set-cookieを呼ぶ -
index.htmlに遷移 -
/api/pingの呼び出し時はid_tokenをBearerトークンとして送る
全て理解できたわけではありませんが、よくできているなぁと感心していました。
(分野の素人にとっては、AIに実装させたものを元に勉強するのも良いのかもしれません)
今回の構成が正解でもないと思うので、認証や認可に関する勉強は続けたいと思います。