0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【コツコツAWS】Amazon Cognito - 静的サイトに認証追加 -

0
Posted at

以前の記事にて、CloudFrontで静的サイトを配信するCDKを実装しました。
サイトにはAPI GatewayとLambdaを統合した機能も実装しましたが、APIのURLを知っていれば、誰でもAPIを直接叩ける状況でした。
今回はこの構成に認証を追加します。

いきなりCDKで実装できるほど、認証周りに詳しくないので、まずはコンソール上で認証基盤を構築し、認証部分が裏側でどう動いているのかを確認することにします。今回は構築部分の記事です。

全体の流れ

認証機能を持たせたサイト配信として、以下の構成を作ります。
Qiita.drawio.png

構築は以下の流れで実施してます。

  1. 配信コンテンツ用のバケット作成, ファイル格納
  2. Amazon Cognitoのユーザープール作成
  3. Lambda関数作成
  4. REST API作成 (API Gateway)
  5. 公開鍵とKey group作成 (CloudFront Signed Cookies用)
  6. CloudFront Distribution作成
  7. CognitoのCallback URLをCloudFrontに更新
  8. S3のコンテンツ修正
  9. 動作確認

1. 配信コンテンツ用のバケット作成, ファイル格納

今回は、配信するコンテンツ以外に、ログイン用のページなども作ります。
全てCloudFrontで配信するため、オリジンとするバケットを用意し、ファイルを格納します。

以下のディレクトリ構成でバケットを作成し、3つのファイルを格納しました。(CloudFrontで配信するため、Block Public AccessはONのまま、静的ウェブホスティングも不要です)

バケットの構成
Bucket
├── index.html (CloudFrontの署名付きCookiesで保護したページ)
└── public/
    └── login.html (ログイン用ページ)
    └── callback.html (リダイレクト用ページ)

格納する3つのファイルはAIに書いてもらってます。

index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Private Site</title>
</head>
<body>
  <h1>Private Site</h1>
  <p>このページが表示できている=CloudFrontのSigned Cookiesで保護が通っています。</p>

  <button id="pingBtn">/api/ping を呼ぶ(Authorization付き)</button>
  <pre id="out"></pre>

  <script>
    const out = document.getElementById("out");
    const btn = document.getElementById("pingBtn");

    btn.addEventListener("click", async () => {
      out.textContent = "calling...";
      const idToken = sessionStorage.getItem("id_token"); // callback.htmlで保存したもの

      if (!idToken) {
        out.textContent = "id_token がありません。login.html からログインし直してください。";
        return;
      }

      const res = await fetch("/api/ping", {
        method: "GET",
        headers: {
          "Authorization": `Bearer ${idToken}`
        },
        credentials: "include",
        cache: "no-store"
      });

      const text = await res.text();
      out.textContent = `status=${res.status}\n${text}`;
    });
  </script>
</body>
</html>
login.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Login</title>
</head>
<body>
  <h1>Login</h1>
  <p>ログイン画面へ遷移します。</p>
  <button id="loginBtn">Login with Cognito</button>

  <script>
    // ===== 設定(後で変更)=====
    const COGNITO_DOMAIN = "後で追記"; 
    const CLIENT_ID = "後で追記";
    const REDIRECT_URI = `${location.origin}/public/callback.html`;

    // 必要最小限: openid(IDトークンを得る)
    const SCOPES = "openid";
    // ===========================

    function base64UrlEncode(uint8) {
      let str = "";
      for (const ch of uint8) str += String.fromCharCode(ch);
      return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    }

    async function sha256(str) {
      const data = new TextEncoder().encode(str);
      const digest = await crypto.subtle.digest("SHA-256", data);
      return new Uint8Array(digest);
    }

    function randomString(len = 64) {
      const bytes = new Uint8Array(len);
      crypto.getRandomValues(bytes);
      return base64UrlEncode(bytes);
    }

    async function startLogin() {
      const state = randomString(32);
      const codeVerifier = randomString(64);
      const codeChallenge = base64UrlEncode(await sha256(codeVerifier));

      // PKCE/CSRF対策用に保存
      sessionStorage.setItem("pkce_state", state);
      sessionStorage.setItem("pkce_verifier", codeVerifier);

      const authorizeUrl =
        `${COGNITO_DOMAIN}/oauth2/authorize` +
        `?response_type=code` +
        `&client_id=${encodeURIComponent(CLIENT_ID)}` +
        `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
        `&scope=${encodeURIComponent(SCOPES)}` +
        `&state=${encodeURIComponent(state)}` +
        `&code_challenge=${encodeURIComponent(codeChallenge)}` +
        `&code_challenge_method=S256`;

      location.href = authorizeUrl;
    }

    document.getElementById("loginBtn").addEventListener("click", () => {
      startLogin().catch(err => {
        console.error(err);
        alert("ログイン開始に失敗しました。コンソールを確認してください。");
      });
    });
  </script>
</body>
</html>
callback.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Callback</title>
</head>
<body>
  <h1>Callback</h1>
  <pre id="log">処理中...</pre>

  <script>
    // ===== 設定(後で変更)=====
    const COGNITO_DOMAIN = "後で追記";
    const CLIENT_ID = "後で追記";
    const REDIRECT_URI = `${location.origin}/public/callback.html`;

    // CloudFront配下の同一オリジンで /auth/set-cookie を呼ぶ
    const SET_COOKIE_ENDPOINT = `${location.origin}/auth/set-cookie`;
    // ===========================

    const logEl = document.getElementById("log");
    function log(msg) { logEl.textContent += "\n" + msg; }

    async function exchangeCodeForTokens(code, codeVerifier) {
      const body = new URLSearchParams({
        grant_type: "authorization_code",
        client_id: CLIENT_ID,
        code,
        redirect_uri: REDIRECT_URI,
        code_verifier: codeVerifier,
      });

      const res = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
      });

      if (!res.ok) {
        const txt = await res.text();
        throw new Error(`token endpoint failed: ${res.status} ${txt}`);
      }
      return res.json(); // { id_token, access_token, refresh_token?, ... }
    }

    async function callSetCookie(idToken) {
      const res = await fetch(SET_COOKIE_ENDPOINT, {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${idToken}`,
        },
        // 同一オリジン想定。明示しておく(Cookieを受け取りたい)
        credentials: "include",
        cache: "no-store",
      });

      if (!res.ok) {
        const txt = await res.text();
        throw new Error(`/auth/set-cookie failed: ${res.status} ${txt}`);
      }
      return res.json();
    }

    async function main() {
      // code/state を取得
      const url = new URL(location.href);
      const code = url.searchParams.get("code");
      const state = url.searchParams.get("state");
      const err = url.searchParams.get("error");

      if (err) {
        throw new Error(`Cognito error: ${err} ${url.searchParams.get("error_description") || ""}`);
      }
      if (!code || !state) {
        throw new Error("Missing code/state in callback URL");
      }

      // state検証
      const expectedState = sessionStorage.getItem("pkce_state");
      const codeVerifier = sessionStorage.getItem("pkce_verifier");
      if (!expectedState || !codeVerifier) {
        throw new Error("Missing pkce_state/pkce_verifier (sessionStorage). Open login page again.");
      }
      if (state !== expectedState) {
        throw new Error("Invalid state (possible CSRF).");
      }

      // URLからcodeを消しておく(見た目・ログ漏洩対策)
      history.replaceState({}, document.title, "/public/callback.html");

      log("Exchanging code for tokens...");
      const tokens = await exchangeCodeForTokens(code, codeVerifier);

      // 最低限: API呼び出しの検証用に id_token を保存(必要なければ消してOK)
      sessionStorage.setItem("id_token", tokens.id_token || "");
      sessionStorage.setItem("access_token", tokens.access_token || "");

      log("Calling /auth/set-cookie...");
      await callSetCookie(tokens.id_token);

      // 使い終わったら消す(残してもいいが最小化)
      sessionStorage.removeItem("pkce_state");
      sessionStorage.removeItem("pkce_verifier");

      log("Done. Redirecting to / ...");
      location.href = "/";
    }

    main().catch(e => {
      console.error(e);
      log("ERROR: " + e.message);
    });
  </script>
</body>
</html>

2. Amazon Cognitoのユーザープール作成

Amazon Cognitoで、認証基盤となるユーザープールを作成します。

  • アプリケーションタイプはシングルページアプリケーション (SPA)
  • サインイン識別子のオプションはメールアドレス
  • 誰でも登録させたいわけではないので、自己登録のチェックは外す
  • サインアップのための必須属性もメールアドレス

ユーザープールができたら、お試し用のユーザーを作成。(メールアドレスとパスワードを設定するだけ)

さらに作成したユーザープールに関して、アプリケーションクライアントの認証フローに「ユーザー名とパスワードを使用してサインイン」を追加。
(簡単な検証なので、他のフローは消しても大丈夫そう)

3. Lambda関数作成

次にAPI Gatewayと統合する2つのLambda関数を作成します。

AuthSetCookie: Cognitoのログインを元に、CloudFrontへのアクセスを許可する関数
APIping: ログイン後のページに付けるAPI用の関数 (ログインしないと使えない)

それぞれ以下の通りです。
(環境変数に秘密鍵をそのまま使うなど、よろしくない部分もあります)

AuthSetCookie
import os
import json
import time
import base64
import logging
from typing import Dict, Any, Optional

import requests
import jwt  # PyJWT
from jwt.algorithms import RSAAlgorithm

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 環境変数
COGNITO_USER_POOL_ID = os.environ["COGNITO_USER_POOL_ID"]
COGNITO_CLIENT_ID = os.environ["COGNITO_CLIENT_ID"]
COGNITO_REGION = os.environ["COGNITO_REGION"]

CLOUDFRONT_DOMAIN = os.environ["CLOUDFRONT_DOMAIN"]
CLOUDFRONT_KEY_PAIR_ID = os.environ["CLOUDFRONT_KEY_PAIR_ID"]
CLOUDFRONT_PRIVATE_KEY_ENV = os.environ["CLOUDFRONT_PRIVATE_KEY"]
# CookieのTTL
COOKIE_TTL_SECONDS = 120
# JWKSの変数 (Issuer: 発行者, JWKS_URL: 公開鍵のリストの場所)
# https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
ISSUER = f"https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
# 処理中は同じJWKSのClientを使いまわすためのグローバル変数
_JWKS_CLIENT: Optional[jwt.PyJWKClient] = None


def _load_private_key_pem() -> bytes:
    """秘密鍵の読み込み"""
    v = CLOUDFRONT_PRIVATE_KEY_ENV.strip()
    return base64.b64decode(v)


def _cloudfront_urlsafe_b64(data: bytes) -> str:
    """CloudFront向けのBase64エンコーディングに変換"""
    # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/signed-cookies-PHP.html
    s = base64.b64encode(data).decode("utf-8")
    return s.replace("+", "-").replace("=", "_").replace("/", "~")


def _jwks_client() -> jwt.PyJWKClient:
    """JWKS Clientの作成"""
    global _JWKS_CLIENT
    if _JWKS_CLIENT is None:
        _JWKS_CLIENT = jwt.PyJWKClient(JWKS_URL)
    return _JWKS_CLIENT


def _verify_cognito_jwt(token: str) -> Dict[str, Any]:
    """CognitoのJWT検証"""
    key = _jwks_client().get_signing_key_from_jwt(token).key

    claims = jwt.decode(
        token,
        key,
        algorithms=["RS256"],
        issuer=ISSUER,
        options={"verify_aud": False},
    )

    token_use = claims.get("token_use")
    if token_use == "id":
        if claims.get("aud") != COGNITO_CLIENT_ID:
            raise ValueError("Invalid aud")
    elif token_use == "access":
        if claims.get("client_id") != COGNITO_CLIENT_ID:
            raise ValueError("Invalid client_id")
    else:
        raise ValueError("Unexpected token_use")

    return claims


def _build_policy(resource: str, expires_epoch: int) -> str:
    """Signed Cookieのポリシーの作成 (有効期限)"""
    return json.dumps(
        {
            "Statement": [
                {
                    "Resource": resource,
                    "Condition": {"DateLessThan": {"AWS:EpochTime": expires_epoch}},
                }
            ]
        },
        separators=(",", ":"),
    )


def _sign(policy_str: str) -> bytes:
    """ポリシーの電子署名"""
    private_key = serialization.load_pem_private_key(_load_private_key_pem(), password=None)
    return private_key.sign(
        policy_str.encode("utf-8"),
        padding.PKCS1v15(),
        hashes.SHA1(),
    )


def _response(status: int, body: Dict[str, Any], set_cookies: Optional[list] = None) -> Dict[str, Any]:
    resp: Dict[str, Any] = {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json",
            "Cache-Control": "no-store",
        },
        "body": json.dumps(body),
    }
    if set_cookies:
        resp["multiValueHeaders"] = {"Set-Cookie": set_cookies}
    return resp


def lambda_handler(event, context):
    """CognitoのJWTを受け取り、CloudFrontのSigned Cookiesを返す"""
    # ヘッダー確認
    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>"})
    # JWT検証
    token = auth.split(" ", 1)[1].strip()
    try:
        claims = _verify_cognito_jwt(token)
    except Exception as e:
        logger.info("JWT verify failed: %s", e)
        return _response(401, {"message": "Invalid token"})
    # ポリシー作成
    exp = int(time.time()) + COOKIE_TTL_SECONDS
    policy = _build_policy(f"https://{CLOUDFRONT_DOMAIN}/*", exp)
    sig = _sign(policy)
    # Signed Cookies作成
    attrs = "Path=/; Secure; HttpOnly; SameSite=Lax"
    set_cookies = [
        f"CloudFront-Policy={_cloudfront_urlsafe_b64(policy.encode())}; {attrs}",
        f"CloudFront-Signature={_cloudfront_urlsafe_b64(sig)}; {attrs}",
        f"CloudFront-Key-Pair-Id={CLOUDFRONT_KEY_PAIR_ID}; {attrs}",
    ]

    logger.info("Issued signed cookies. token_use=%s sub=%s exp=%s",
                claims.get("token_use"), claims.get("sub"), exp)

    return _response(200, {"ok": True, "expires_epoch": exp}, set_cookies=set_cookies)
APIping
import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    # APIにAuthorizationがある時に、200を返すだけの関数 (認証ない場合はAPIGW側で401を返す)
    headers = event.get("headers") or {}
    auth = headers.get("Authorization")
    logger.info("has_authorization_header=%s", bool(auth))

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"ok": True}),
    }

なお、1つ目の関数はLayerを使用する必要があります。
以下のrequirements.txtとコマンドで作成したものをLambdaにアップロードして使用しました。

requirements.txt
PyJWT==2.10.1
cryptography==45.0.4
requests==2.32.3
Layer用のzipファイル作成
mkdir -p layer/python
pip install -r requirements.txt -t layer/python --upgrade --no-cache-dir

cd layer
zip -r layer.zip python

4. REST API作成 (API Gateway)

CloudFrontで配信するコンテンツとLambda関数を繋ぐAPIを作成します。

4-1. リソース作成

今回は以下の構成でAPIのリソースを作成。

APIのリソース構成
/
├── auth
│   └── set-cookie (POST)   # ログイン検証用のLambda関数(AuthSetCookie)に接続
└── api
    └── ping (GET)   # ping用のLambda関数(APIping)に接続

次に、Cognito User PoolのAuthorizerを作成。
API Gatewayの左メニューにある「オーソライザー」を選び、以下の設定で作成。

項目 設定値
Type Cognito
User pool 作成したUser Pool
Token source Authorization

そして、/api/pingのGETメソッドについて、メソッドリクエストのAuthorizerを作成したものに変更。最後にAPIをDeployして反映。

4-2. APIの認証確認

pingのGETメソッドに認証機能が反映されているか確認します。
まず、認証なしの場合を確認します。

# Authorization なし → 401になる
curl -i {APIのURL}/api/ping

# 出力
# HTTP/2 401 
# date: ...
# content-type: application/json
# ...
# x-amzn-errortype: UnauthorizedException
# ...
# {"message":"Unauthorized"}

Authorizationヘッダーがないため、401 (Unauthorized)が返されました。
次にCognitoから認証をもらった上で実行します。
まずはAWS CLIで以下のコマンドを実行し、出力にあるIdTokenを控えます。

Cognitoの認証取得
aws cognito-idp initiate-auth \
  --region {リージョン} \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id {Cognitoで作成したアプリケーションクライアントのID} \
  --auth-parameters USERNAME={メールアドレス},PASSWORD={設定したパスワード}

控えたTokenを使って、今度は認証ありの状態でGETメソッドを実行します。

# Authorization あり → 200でOK
curl -i \
   -H "Authorization: Bearer {IdToken}" \
   {APIのURL}/api/ping

# 出力
# HTTP/2 200 
# date: ...
# content-type: application/json
# ...
# {"ok": true}

無事にAPIが200を返し、API GatewayのCognito User Pool AuthorizerがTokenを検証して通し、Lambdaの実行まで到達したことを確認できました。
(CloudWatch Logsを見ても呼ばれていることが確認できます)

5. 公開鍵とKey group作成 (CloudFront Signed Cookies用)

CloudFrontの署名付きCookiesによるアクセス制限を行うため、鍵を作ります。

5-1. 鍵ペアをローカルで作成(例)

CloudFrontに登録する公開鍵とLambdaが署名に使う秘密鍵を生成します。

鍵作成
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem

5-2. CloudFrontにPublic keyを登録

CloudFrontコンソールの「キー管理」の「パブリックキー」から「パブリックキーの作成」を選択し、5-1で作成した公開鍵を貼り付けて保存します。

5-3. Key group 作成

次に「キー管理」の「キーグループ」から「キーグループの作成」を選択し、5-2で作成した鍵を追加して作成します。

6. CloudFront Distribution作成

コンテンツ配信のため、CloudFront Distributionを作成します。

  • プランは無料プランを選択
  • オリジンをS3にして、1で作成した3バケットを設定 (パスはそのまま)
  • その他の設定もデフォルトで作成 (セキュリティとか)

次にAPIのオリジンを追加。
オリジンのドメインとして、4で作成したAPI Gatewayを選択し、プロトコルは「HTTPSのみ」、オリジンパスは設定したステージ(/prod)を入力して作成。

後はビヘイビアの設定です。Default (*)の設定は以下の通り。
アクセスを制限して、5で作成した鍵を認可タイプにしています。

項目 設定
オリジン 1で作成したS3バケット
ビューワープロトコルポリシー Redirect HTTP to HTTPS
許可された HTTP メソッド GET, HEAD
ビューワーのアクセスを制限する Yes
信頼された認可タイプ Trusted key groups (5-3で作成したもの)

/public/*はビューワーのアクセスを制限しない形で配信。
/auth/*/api/*のAPIについては、以下のように設定。

項目 設定
オリジン 4で作成したAPI
ビューワープロトコルポリシー Redirect HTTP to HTTPS
許可された HTTP メソッド GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
ビューワーのアクセスを制限する No
キャッシュポリシー CachingDisabled (キャッシュ無効)
オリジンリクエストポリシー AllViewerExceptHostHeader

APIはCognito側で認証するので、ビューワーのアクセスを制限をしていません。 (APIを署名付きCookieで保護するのは一般的ではないらしい...?)
また、キャッシュは無効化し(必ずオリジンに行かせ)、AuthorizationヘッダーがAPIに転送されるようにしています。

オリジンリクエストポリシーに AllViewer を選ぶと、Hostヘッダーまでオリジンに転送されることがあります。
すると、API GatewayへのリクエストがCloudFrontに対するHost (dxxxxx.cloudfront.net)のままになってしまい、403(Forbidden)になることがあるため、AllViewerExceptHostHeaderが推奨のようです。

最後に、デフォルトルートオブジェクトをindex.htmlに修正して、設定は完了です。

7. CognitoのCallback URLをCloudFrontに更新

6で作成したディストリビューションをCognito側に設定します。
2で作成したユーザープールのアプリケーションクライアントから「ログインページ」を選択し、Callback URLをhttps://{ディストリビューションドメイン名}/public/callback.htmlに、Sign out URLをhttps://{ディストリビューションドメイン名}/public/login.htmlに変更します。

8. S3のコンテンツ修正

7までに作成したリソースの内容を元にS3のファイルの中身を修正。
1で作成したlogin.htmlcallback.htmlについて、COGNITO_DOMAINCLIENT_IDを追記すれば完了です。
(CognitoのアプリケーションクライアントのQuick Setupガイドに記載があります)

9. 動作確認

細かい確認は別で調べることにして、想定している挙動をするかだけを確認します。
まず、CloudFrontのデフォルトの挙動として、ブラウザでhttps://dxxxxx.cloudfront.net/にアクセスすると、MissingKeyとしてアクセスできないことが確認できます。
次にhttps://dxxxxx.cloudfront.net/public/login.htmlにアクセスすると、作成したログインページに行き、ログインボタンを押せばCognitoのログインページに飛びます。
ログインするとPrivateのページに飛び、/api/pingのAPIも実行できました。
(認証なしだと弾かれるのは4で確認済み)

まとめ

CloudFrontでのコンテンツ配信(静的サイトやAPI)に関して、Cognitoを使った認証基盤を追加しました。
当初の目的である「アクセスやAPIの実行を制限すること」は達成できたと思います。
一方でJWTや署名付きCookiesがどのように効いているのかは、まだ理解できていないので、次は中身の確認を進めようと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?