開発途中の静的サイトや社内向けのドキュメントなど特定の人たちにしか見れないようにしたいというケースは多いと思います。AWSでこのような要件があった場合、どうすればよいでしょうか?
今回は、このケースを想定して作っていきます!
上記のような構成で考えてみました。まずは静的コンテンツの配信するための基本であるCloudFrontからS3への流れ、そしてCloudFrontのイベントをトリガーとしてLambda@Edgeを実行し、その中でCognitoとGoogle認証を連携させるというものです。
AWS CDK(TypeScript)とlambdaはTypeScriptを使って実装しました。
リクエストがCloudFrontからコンテンツのあるS3に届く前に、そのリクエストが正しいのかをチェックする必要があります。CloudFrontディストリビューションには各キャッシュ動作に、特定のCloudFrontイベントの発生時にLambda関数を実行させるトリガーを4つまで追加できるので、この機能を使用します。CloudFrontで使用するLambdaはLambdaなのですが、呼び方はLambda@Edge
と言います。lambdaと違いNode.js
上でしか実行されない、バージニア北部のみ、環境変数は使えないなどの制約があります。
Lambda@Edge 関数のトリガーに使用できる CloudFront イベント
Lambda@Edge
でCognitoのapiを実行し、認証トークンを発行するという複雑な処理を書かなければいけないのですが、AWS Labsから便利なライブラリが出ていますので、これを使います。
使い方は実に簡単です!
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ファイルを含みません。
パス 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が返ってしまいます。
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;
}
ちなみにですが、S3だけで静的サイトホスティングを実装した場合はインデックスドキュメントの設定項目があるのでわざわざ関数を用意する必要はありません。
これも地味にハマるとこですがビューワーリクエストにCloudFront FunctionsとLambda@Edgeを組み合わせることはできません。
これは実際に僕がハマったのですが、Lambda@EdgeにCognito@Edgeを使用して認証機能を追加した後で、インデックスドキュメントを追加する必要に気づいたので、認証とインデックスドキュメントの関数を分けようとしてハマりました。
CloudFront FunctionsはLambda@Edgeより手前で実行されるので、問題ない実装だと思い込んでいました、、、
IP制限はCloudFrontにAWS WAFをアタッチすることでも実現できますが、今回はLambda@Edgeでやります。AWS WAFは地味に固定料金がかかりますし、IP制限だけでしたらLambda@Edgeでも十分です。
// アクセス許可する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};
Google認証を使用したSing Upの実装です。
AWSにやり方が載っていたので、これを参考に作っていきます。
Amazon Cognito ユーザープールでフェデレーションアイデンティティプロバイダーとして Google を設定するにはどうすればよいですか?
コンソールをポチポチするだけなのですぐに設定は終わります。
上記の通り設定が終われば、ウェブアプリケーションのクライアントID画面は以下のようになってると思います。
OAuth同意画面とは、よく見るアレです。
Google認証を使ってきたユーザーのメールアドレスを確認したいということはあると思います。この時に便利なのがlambdaトリガーです。
まず、デフォルトのGoogle認証ではcognito側にemailの情報は渡されないので、属性マッピングでユーザープール属性のEmail
を設定します。
// AWS CDK
new UserPoolIdentityProviderGoogle(this.stack, "Google", {
userPool,
clientId,
clientSecret,
scopes: ["Email"],
attributeMapping: {
email: ProviderAttribute.GOOGLE_EMAIL
}
});
そして、次にアタッチするlambdaを作成します。これは、lambdaなのでリージョンの制約や環境変数の制約はありません。今回は、Emailアドレスのドメインがあっている場合は、Sing Upを行い、違う場合はエラーを返すという単純な処理にします。
// 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トリガー
にアタッチします。
// AWS CDK
this.userPool.addTrigger(
UserPoolOperation.PRE_SIGN_UP,
this.signUpTriggerLambda
);
これでデプロイすると以下のようにアタッチできていると思います。
これで終了です。