shou.com
JP / EN

静的サイト(S3)に認証をつける方法(Cognitoで)

Tue Feb 28, 2023
Sat Apr 20, 2024

開発途中の静的サイトや社内向けのドキュメントなど特定の人たちにしか見れないようにしたいというケースは多いと思います。AWSでこのような要件があった場合、どうすればよいでしょうか?

今回は、このケースを想定して作っていきます!

要件

  • コンテンツは静的サイト
  • IP制限は必要
  • 認証にはGoogle認証を使いたい

構成

静的サイト(S3)に認証をつける方法(Cognitoで)

上記のような構成で考えてみました。まずは静的コンテンツの配信するための基本であるCloudFrontからS3への流れ、そしてCloudFrontのイベントをトリガーとしてLambda@Edgeを実行し、その中でCognitoとGoogle認証を連携させるというものです。

AWS CDK(TypeScript)とlambdaはTypeScriptを使って実装しました。

①CloudFrontのビューワーリクエスト

リクエストがCloudFrontからコンテンツのあるS3に届く前に、そのリクエストが正しいのかをチェックする必要があります。CloudFrontディストリビューションには各キャッシュ動作に、特定のCloudFrontイベントの発生時にLambda関数を実行させるトリガーを4つまで追加できるので、この機能を使用します。CloudFrontで使用するLambdaはLambdaなのですが、呼び方はLambda@Edgeと言います。lambdaと違いNode.js上でしか実行されない、バージニア北部のみ、環境変数は使えないなどの制約があります。

チュートリアル: シンプルな Lambda@Edge 関数の作成


静的サイト(S3)に認証をつける方法(Cognitoで)

Lambda@Edge 関数のトリガーに使用できる CloudFront イベント

  • ビューワーリクエスト ← 今回はこれがトリガー
  • オリジンリクエスト
  • オリジンレスポンス
  • ビューワーレスポンス

Lambda@Edge関数をトリガーできるCloudFrontイベント

②認証トークンの発行

Lambda@EdgeでCognitoのapiを実行し、認証トークンを発行するという複雑な処理を書かなければいけないのですが、AWS Labsから便利なライブラリが出ていますので、これを使います。

Cognito@Edge

使い方は実に簡単です!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const { Authenticator } = require('cognito-at-edge');

const authenticator = new Authenticator({
  // Replace these parameter values with those of your own environment
  region: 'us-east-1', // user pool region
  userPoolId: 'us-east-1_tyo1a1FHH', // user pool ID
  userPoolAppId: '63gcbm2jmskokurt5ku9fhejc6', // user pool app client ID
  userPoolDomain: 'domain.auth.us-east-1.amazoncognito.com', // user pool domain
});

exports.handler = async (request) => authenticator.handle(request);

Lambda@Edgeは環境変数が使えないので、このようにcognitoのkeyをベタ書きしなければならないのですが、AWS Secrets Managerを使いapi keyのベタ書きを回避します。

AWS CDKやCloudFormationで書いている場合はcognitoのkeyをSecrets Managerに参照させるほうがいいでしょう。cognitoを作成→Secrets ManagerでRef関数などでcognitoのkeyを参照→AWS Secrets Managerのaws sdkを使ってシークレットを取得します。

Secrets Managerのsdkを使う場合に気をつけなくてはいけないのがレスポンスの速度です。cognitoのkeyをつどつど取得しているようなコードの書き方だとS3+CloudFrontという構成の割にレスポンスが遅くなり、だいたい2秒ほどかかります。これを回避するためにもLambdaのインメモリキャッシュを使いましょう。インメモリキャッシュを利用する書き方にすれば初回は遅いものの2回目からはキャッシュを利用するので、高速化できます。

インデックスドキュメントの設定

CloudFront + S3で静的サイトをホスティングする際に地味に厄介なのがインデックスドキュメントの設定です。

このサイトでも使っているHugoもそうですが、多くの静的サイトジェネレータはバスにhtmlファイルを含みません。

1
2
3
4
5
パス  https://shou2017.com/about/

こうは生成されない

パス https://shou2017.com/about/index.html

ApacheやNginxなどのWebサーバではデフォルトでindex.htmlが表示されますし、静的サイトホスティングサービスで有名なNetlify、GitHub Pagesなどでもデフォルトでやってくれるので特に気にすることもないのですが、CloudFront + S3だとこれがありません。なのでファイル名を含まないurlのリクエストがあった場合にindex.htmlを追加する処理をLambda@Edgeに追加します。これがないと404が返ってしまいます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}

index.htmlを追加してファイル名を含まないURLをリクエストする

ちなみにですが、S3だけで静的サイトホスティングを実装した場合はインデックスドキュメントの設定項目があるのでわざわざ関数を用意する必要はありません。

ウェブサイトのホスティングの有効化

CloudFront FunctionsとLambda@Edgeを組み合わせることはできない

これも地味にハマるとこですがビューワーリクエストにCloudFront FunctionsとLambda@Edgeを組み合わせることはできません。

これは実際に僕がハマったのですが、Lambda@EdgeにCognito@Edgeを使用して認証機能を追加した後で、インデックスドキュメントを追加する必要に気づいたので、認証とインデックスドキュメントの関数を分けようとしてハマりました。

CloudFront FunctionsはLambda@Edgeより手前で実行されるので、問題ない実装だと思い込んでいました、、、

IP制限

IP制限はCloudFrontにAWS WAFをアタッチすることでも実現できますが、今回はLambda@Edgeでやります。AWS WAFは地味に固定料金がかかりますし、IP制限だけでしたらLambda@Edgeでも十分です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// アクセス許可するIPを設定
const IP_WHITE_LIST = [];

async function handler(request: any) {
  // クライアントIPが、アクセス許可するIPに含まれていればtrueを返す
  const isPermittedIp = IP_WHITE_LIST.includes(
    request.Records[0].cf.request.clientIp
  );
  if (isPermittedIp) {
    // trueの場合の処理
  } else {
    const response = {
      statusCode: 403,
      statusDescription: "Forbidden"
    };
    // falseの場合の処理
    return response;
  }
}
export {handler};

Sing Up

Google認証を使用したSing Upの実装です。

AWSにやり方が載っていたので、これを参考に作っていきます。

Amazon Cognito ユーザープールでフェデレーションアイデンティティプロバイダーとして Google を設定するにはどうすればよいですか?

  1. Google API コンソールプロジェクトを作成する
  2. OAuth 同意画面を設定する
  3. OAuth 2.0 クライアントの認証情報を取得する
  4. ユーザープールでGoogleをフェデレーティッドIdPとして設定する

コンソールをポチポチするだけなのですぐに設定は終わります。

上記の通り設定が終われば、ウェブアプリケーションのクライアントID画面は以下のようになってると思います。

静的サイト(S3)に認証をつける方法(Cognitoで)

OAuth同意画面とは、よく見るアレです。

静的サイト(S3)に認証をつける方法(Cognitoで)

Sing Up時のLambdaトリガーを実装

Google認証を使ってきたユーザーのメールアドレスを確認したいということはあると思います。この時に便利なのがlambdaトリガーです。

まず、デフォルトのGoogle認証ではcognito側にemailの情報は渡されないので、属性マッピングでユーザープール属性のEmailを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// AWS CDK
new UserPoolIdentityProviderGoogle(this.stack, "Google", {
  userPool,
  clientId,
  clientSecret,
  scopes: ["Email"],
  attributeMapping: {
    email: ProviderAttribute.GOOGLE_EMAIL
  }
});

そして、次にアタッチするlambdaを作成します。これは、lambdaなのでリージョンの制約や環境変数の制約はありません。今回は、Emailアドレスのドメインがあっている場合は、Sing Upを行い、違う場合はエラーを返すという単純な処理にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// signUpTriggerLambda
async function handler(event: any) {
  const email = event.request.userAttributes.email;
  const domain = email.substring(email.indexOf("@") + 1);
  if (domain !== "shou2017.com") {
    console.error("This user cannot be registered");
    return null;
  } else {
    return event;
  }
}
export {handler};

先ほど作ったsignUpTriggerLambdaをlambdaトリガーのサインアップ前Lambdaトリガーにアタッチします。

1
2
3
4
5
// AWS CDK
this.userPool.addTrigger(
  UserPoolOperation.PRE_SIGN_UP,
  this.signUpTriggerLambda
);

これでデプロイすると以下のようにアタッチできていると思います。

静的サイト(S3)に認証をつける方法(Cognitoで)

これで終了です。

See Also