Amazon S3 による Web ページの作成 (CloudFront/Cognito)

2023年2月26日

はじめに

S3 バケットはパブリックアクセスが推奨されていない。S3 バケットの内容にパブリックアクセスを無効にした状態でアクセスするには、CloudFront を用いる。CloudFront を用いると、アクセスに対する柔軟な処理を追加したり、WAF を導入したり、キャッシュを用いたり (CDN サービスだからこれが本来の利用方法だが) できる。

S3 と CloudFront で静的 Web サイトを公開する

ディストリビューションの作成

公開したい S3 バケットを作成しておく。

CloudFront: ディストリビューションの作成

  1. [ディストリビューションを作成]
  2. "オリジンドメイン" でバケットを選択。
  3. "S3 バケットアクセス" で "Origin access control settings (recommended)" を選択。
  4. "Origin access control" の [コントロール設定を作成]、[作成]
  5. "デフォルトのキャッシュビヘイビア" は、とりあえずそのまま。
  6. "設定" の "料金クラス" は、アクセスする地域を含むものを選ぶ。
  7. WAF が必要なら、ここで設定する。
  8. アクセスログが必要なら、"標準ログ記録" を "オン" にして、S3 バケットを選択する。
  9. [ディストリビューションを作成]

CloudFront: S3 バケットの設定

  1. 作成したディストリビューションを選び、オリジンの編集画面を開く。
  2. "バケットポリシー" で [ポリシーをコピー]、"S3バケットアクセス許可に移動" のリンクをクリック。

S3

  1. バケットポリシーにコピーしたポリシーをペースト。必要に応じて編集。
  2. バケットの "パブリックアクセスをすべてブロック" を "オン" にする。

バケットポリシーは次のようになるかもしれない。

{
   "Version": "2008-10-17",
   "Id": "PolicyForCloudFrontPrivateContent",
   "Statement": [
      {
         "Sid": "AllowCloudFrontServicePrincipal",
         "Effect": "Allow",
         "Principal": {
            "Service": "cloudfront.amazonaws.com"
         },
         "Action": "s3:GetObject",
         "Resource": [
               "arn:aws:s3:::<バケット名>/index.html",
               "arn:aws:s3:::<バケット名>/default.css",
               "arn:aws:s3:::<バケット名>/*.js",
               "arn:aws:s3:::<バケット名>/libs/*",
               "arn:aws:s3:::<バケット名>/fig/*"
           ],
         "Condition": {
            "StringEquals": {
             "AWS:SourceArn": "arn:aws:cloudfront::<アカウント>:distribution/<ディストリビューション>"
            }
         }
      }
   ]
}

確認: https://(ディストリビューションドメイン名)/index.html などでアクセスできるか確認する。

※ディストリビューションのデフォルトルートオブジェクトやオリジンのオリジンパスで URL のアクセス方法を変えられる。

キャッシュ

キャッシュの確認

Firefox の場合、開発ウインドウ (F12) の [ネットワーク] で項目を選んだときに、"x-cache" の値が "Hit from cloudfront" になっている場合は、CloudFront のキャッシュが効いている。

キャッシュ削除

  1. CloudFront の [キャッシュ削除] タブの [キャッシュ削除を作成] を押す。
  2. "オブジェクトパスを追加" で、キャッシュを削除するパスを追加する。

キャッシュは 24 時間保持するらしいが、やりすぎるとお金がかかるらしい。

参考

キャッシュ無効化

CloudFront の [ビヘイビア] の編集で、"キャッシュポリシー" に "CachingDisabled" を選ぶ。

関数

CloudFront で使える関数として、Lambda@Edge と CloudFront Functions がある。Lambda@Edge は 5 s、CloudFront Functions は 1 ms で処理する必要がある。CloudFront Functions のほうが費用は安い。ただし、CloudFront Functions でサポートするのは ECMAScript 5.1 相当らしい。Lambda@Edge はリージョンをバージニア北部 (us-east-1) にする必要がある。

参考

Lambda@Edge

  1. リージョンとして "us-east-1" を選ぶ。
  2. Lambda で関数を作る。"一から作成" で、実行ロールを "AWS ポリシーテンプレートから新しいロールを作成" を選び、ポリシーテンプレートで "基本的な Lambda@Edge のアクセス権限 (CloudFront トリガーの場合)" を選ぶ (ロール名は "lambda-edge-role" とか適当に設定する)。ここでは Node.js 18.x を使用。
  3. [アクション]-[Lambda@Edge へのデプロイ] を押す。
  4. "ディストリビューション"、"キャッシュ動作"、"CloudFront イベント" を選択する。

CloudFront イベント

  • ビューワーリクエスト: キャッシュの確認前に呼ばれる
  • オリジンリクエスト: キャッシュがない場合に呼ばれる

お試し関数

export const handler = (event, context, callback) => {
   const request = event.Records[0].cf.request;
   console.log(JSON.stringify(request, null, 2));
   callback(null, request);
};

ビューワーリクエストに設定する。

CloudWatch のログは、アクセスされたリージョンに残るようである。

関数を更新する場合は、[Lambda@Edge へのデプロイ] で "この関数で既存の CloudFront トリガーを使用" を選べばよい。

ただ転送するだけの場合は、次のようにする。

export const handler = (event, context, callback) => {
   const response = {
       status: "302",
       statusDescription: "Found",
       headers: {
           location: [{
               key: "Location",
               value: <URL>
           }]
       }
   };
   callback(null, response);
};

Cognito 認証

S3 バケットの内容を直接公開する場合、Cognito 認証を用いても、コードを実行するためにある程度内容を公開する必要がある。CloudFront を用いると、CloudFront 側で Cognito 認証を行うことにより、認証を通らない限り S3 バケットの内容を少しも公開する必要がなくなる。

Cognito の認証にはいくつかのタイプがあり、認証の URL に "#token_id=..." でトークンを付与してくれるもの (暗黙的な付与) と、"?code=..." でトークン交換用のコードを付与してくるもの (認証コード付与) がある。前者は CloudFront で処理する方法がわからなかったし、そもそもトークンがモロ見えなのもアレなので、ここではコードを使う方法を用いる。

Cognito のアプリケーションクライアントを「秘密クライアント」にする (あとから切り替えられないので注意)。これでクライアントシークレットを用いることができる。"OAuth 2.0 許可タイプ" で "認証コード付与" を選ぶ。URL に code が付与されるので、それを使ってトークンエンドポイントからトークンをもらう必要がある。

Lambda@Edge の例

Lambda@Edge で Cognito 認証するコードの例を示す。流れとしては以下のようになっている。

  1. index.html にアクセスされたら、querystring の中に code をもっているか確認する。
  2. code を持っていなかったら、cookie の中に有効な id_token をもっているか確認する。
  3. code も id_token もなければ、Cognito 認証画面に飛ばす。
  4. 認証を経て、index.html に戻ってきたら、code を持っているはずなので、それを用いて Cognito のトークンエンドポイントからトークンをもらえるよう試みる。
  5. トークンをもらえたら、一応自分の要求したものだと確認したあと、cookie に id_token を設定して index.html に飛ばす。
  6. 今度は id_token をもってきているので、それが自分の要求したものだと確認したあと、index.html 本体 (S3 にあるファイル) へのアクセスを許可する。

トークンの妥当性はもう少しちゃんと確認したほうがよさそうだが、どうせ S3 側で弾かれるので今回はこうしている。

S3 の index.html 側では、cookie から id_token を取り出して使う。id_token がなければ login に飛ばす。トークンによる認証がうまくいかなければ logout に飛ばす。logout では reset に飛ばしているが、cookie のトークンを空にするためである。

import https from "https";
import querystring from "querystring";

const cloudFrontName = <CloudFront 名>
const cognitoDomainName = <Cognito ドメイン名>
const cognitoAppClientId = <Cognito アプリクライアント ID>
const cognitoAppClientSecret = <Cognito アプリクライアントシークレット>

const cloudFrontUrl = `https://${cloudFrontName}.cloudfront.net`;
const indexUrl = `${cloudFrontUrl}/index.html`;
const resetUrl = `${cloudFrontUrl}/reset`;

const cognitoDomainUrl
   = `https://${cognitoDomainName}.auth.ap-northeast-1.amazoncognito.com`;
const cognitoTokenUrl = `${cognitoDomainUrl}/oauth2/token`;
const cognitoLoginUrl
   = `${cognitoDomainUrl}/login?response_type=code`
   + `&client_id=${cognitoAppClientId}&redirect_uri=${indexUrl}`;
const cognitoLogoutUrl
   = `${cognitoDomainUrl}/logout`
   + `?client_id=${cognitoAppClientId}&logout_uri=${indexUrl}`;


function goLogin(callback) {
   console.log("goLogin");

   const response = {
       status: "302",
       statusDescription: "Found",
       headers: {
           location: [{
               key: "location",
               value: cognitoLoginUrl
           }]
       }
   };
   callback(null, response);
}


function goLogout(callback) {
   console.log("goLogout");

   const response = {
       status: "302",
       statusDescription: "Found",
       headers: {
           location: [{
               key: "location",
               value: cognitoLogoutUrl
           }]
       }
   };

   callback(null, response);
}


function convertCookieToJSON(cookie) {
 if (cookie.trim() === "") {
   return {};
 }

 const cookies = cookie.split("; ");

 let json = {};

 for (const s of cookies) {
   const item = s.split("=");
   json[item[0]] = item[1];
 }

 return json;
}


function goReset(request, callback) {
   console.log("goReset");

   const response = {
       status: "302",
       statusDescription: "Found",
       headers: {
           location: [{
               key: "location",
               value: resetUrl
           }],
           "set-cookie": [{
               key: "set-cookie",
               value: "id_token="
           }]
       }
   };

   callback(null, response);
}


function validIDToken(idToken) {
   if (idToken === "") return ;

   const tokens = idToken.split(".");
   const obj = JSON.parse(Buffer.from(tokens[1], "base64").toString());
   console.log(JSON.stringify(obj, null, 2));

   if (obj.aud !== cognitoAppClientId) {
       return false;
   }

   return true;
}


function checkIDToken(idToken, request, callback) {
   if (validIDToken(idToken)) {
       console.log("ok");

       let date = new Date(Date.now());
       date.setMonth(date.getMonth() + 1);
       date = date.toUTCString();

       const response = {
           status: "302",
           statusDescription: "Found",
           headers: {
               "location": [{
                   key: "location",
                   value: indexUrl
               }],
               "set-cookie": [{
                   "key": "set-cookie",
                   "value": `id_token=${idToken}; samesite=lax; expires=${date}`
               }]
           }
       };
       callback(null, response);
   } else {
       goLogout(callback);
   }
}


function checkCode(code, request, callback) {
   const basic = "Basic "
       + Buffer.from(cognitoAppClientId + ':' + cognitoAppClientSecret)
       .toString("base64");

   const options = {
       method: "POST",
       headers: {
           "Content-Type": "application/x-www-form-urlencoded",
           "Authorization": basic
       }
   };

   console.log(options);

   const req = https.request(cognitoTokenUrl, options, (res) => {
       console.log("statusCode:", res.statusCode);
       console.log("headers:", res.headers);

       res.on("data", (d) => {
           console.log(d.toString());
           const idToken = JSON.parse(d.toString()).id_token;

           if (idToken === undefined) {
               goLogout(callback);
           } else {
               checkIDToken(idToken, request, callback);
           }
       });
   });

   req.on("error", (e) => {
       console.error(e);
   });

   const params = {
       grant_type: "authorization_code",
       client_id: cognitoAppClientId,
       redirect_uri: indexUrl,
       code: code
   };

   console.log(params);

   const data = new URLSearchParams();
   for(const name in params) {
       data.append(name, params[name]);
   }

   console.log(data.toString());

   req.write(data.toString());
   req.end();
}


function forbidden(callback) {
   console.log("forbidden");

   const response = {
       status: "403",
       statusDescription: "Forbidden",
       headers: {
           "content-type": [{
               key: "Content-Type",
               value: "text/html"
           }]
       },
       body: ""
   };
   callback(null, response);
}


function getQueryString(request, param) {
   if (request.querystring !== undefined && request.querystring !== "") {
       return querystring.parse(request.querystring)[param];
   }
   return undefined;
}


function getCode(request) {
   return getQueryString(request, "code");
}


function getCookie(cookie, label) {
 const json = convertCookieToJSON(cookie);
 return json[label];
}


function getIDToken(request) {
   const cookie = request.headers.cookie;
   if (cookie !== undefined && cookie[0].value !== "") {
       return getCookie(cookie[0].value, "id_token");
   }
   return undefined;
}


export const handler = (event, context, callback) => {
   const request = event.Records[0].cf.request;

   console.log(JSON.stringify(event, null, 2));

   /* index.html */
   if (request.uri === "/index.html") {
       const code = getCode(request);
       const idToken = getIDToken(request);

       console.log("code", code);
       console.log("idToken", idToken);

       /* code? */
       if (code !== undefined) {
           checkCode(code, request, callback);
       /* ID token? */
       } else if (idToken !== undefined && validIDToken(idToken)) {
           callback(null, request);
       } else {
           goLogin(callback);
       }
   /* login */
   } else if (request.uri === "/login") {
       goLogin(callback);
   /* logout */
   } else if (request.uri === "logout") {
       if (request.headers.cookie !== undefined) {
           goReset(request, callback);
       } else {
           goLogout(callback);
       }
   /* reset */
   } else if (request.uri === "/reset") {
       goLogout(callback);
   /* others */
   } else {
       if (request.headers.referer === undefined) {
           forbidden(callback);
       } else {
           callback(null, request);
       }
   }
};