😊

AWSを利用した会員サイトをサーバーレスで実装しました

2021/06/12に公開

AWSを利用した会員サイトをサーバーレスで実装しました。
始めはCognitoとS3でなんとかなるだろうと思っていましたが、
いざやってみるとどうにも思い通りにならず、四苦八苦しました。

調べてみると、どうやらCognitoとS3だけでは、S3に対してユーザー単位のコンテンツにしかアクセスできず、ユーザー共有のコンテンツにはアクセスできないみたいです。(私の調査不足/理解不足かもしれませんが...)

というわけで、以下のAWSの機能を利用して実装してみました。

  • S3
  • CloudFront
  • Cognito
  • API Gateway
  • Lambda

処理手順

  1. S3に公開フォルダと会員限定フォルダを作成する。
  2. S3に対してはCloudFrontを通してアクセスする。
  3. S3の会員限定フォルダへは、CloudFrontの閲覧者のアクセスを制限する機能のCookieを使用する。
  4. Cognitoで認証が成功した場合、認証情報のJWTトークンをAPI Gatewayのオーソライザーで検証する。
  5. API Gatewayのオーソライザーで検証が成功した場合、同APIをトリガーに実行されるLambda関数から、Cookieを取得する。
  6. 取得したCookieを使用して、会員限定ページへ遷移する。
  7. サインアウトするとき、Cookieを削除する。

つまり、会員ページへのアクセス制限にCloudFrontを使用し、
CognitoはCloudFrontで必要なCookieを取得するための認証ということになります。

1. S3バケットを作成する

一般公開するpublicフォルダと、ログインしたユーザーがアクセス可能なprivateフォルダを作成します。

\
├── private
│   ├── private-index.bundle.js
│   ├── private-index.bundle.js.map
│   └── private-index.html
└── public
    ├── public-index.bundle.js
    ├── public-index.bundle.js.map
    └── public-index.html

Static website hostingや、バケットポリシーを設定する必要はありません。

2. CroudFrontを設定する

手順1で作成したバケットにCloudFrontを介してアクセスするようにします。
privateフォルダにCookieを使用してアクセスするBehaviorを追加します。
これでprivateフォルダは、Cookieを指定しないとアクセスできないフォルダになります。

3. Cognitoを作成する

ユーザープールを作成する

作成について特筆すべき点はありませんが、以下の情報を控えておきます。

  • ユーザープールID
  • アプリクライアントID

IDプールを作成する

以下の点を留意してください。

  • 認証プロバイダーには、ユーザープールのユーザープールIDアプリクライアントIDを指定します。
  • IDプールのIDを控えておきます。

4. CloudFrontへアクセスするためのCookieを作成する

CloudFrontのキーペアを作成する

AWSアカウントにrootでログインし、CloudFront のキーペアを作成します。

  1. AWSアカウントにrootログインします。
  2. ルートアクセスキーの削除を選択します。
  3. セキュリティ認証情報の管理を選択します。
  4. CloudFront のキーペアを選択します。
  5. 新しいキーペアの作成を選択し、プライベートキーファイル(pk-XXXXXXXX.pem)とパブリックキーファイル(rsa-XXXXXXXX.pem)をダウンロードします。
  • 画面に表示されるアクセスキーIDを控えておいてください。

CloudFrontへアクセスするためのCookieを生成する

CloudFrontへアクセスするためには、3つのCookieが必要です。

  • key-pair
  • policy
  • signature
key-pair

CloudFront のキーペアアクセスキーIDです。

policy

Jsonから改行とインデントを取り除いたものになります。

  • ResourceはアクセスするURLを設定します。(CloudFrontのURL)
  • 最低限DateLessThan(URLの有効期限切れ日時)を設定しておく必要があります。
{
   "Statement":[
      {
         "Resource":"https://xxxxxxxx.cloudfront.net/*",
         "Condition":{
            "DateLessThan":{
               "AWS:EpochTime":2595364179
            }
         }
      }
   ]
}

インデントと改行を取り除いたものをファイルとして保存します。(ここではpolicy.jsonとします)

{"Statement":[{"Resource":"https://xxxxxxxx.cloudfront.net/*","Condition":{"DateLessThan":{"AWS:EpochTime":2595364179}}}]}

以下のコマンドの出力結果がpolicyの値となります。

$ cat ./policy.json | openssl base64 | tr '+=/' '-_~'
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
signature

以下のコマンドの出力結果がsignatureの値となります。

$ cat policy.json | openssl sha1 -sign <プライベートキーファイル> | openssl base64 | tr '+=/' '-_~'
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXX

5. Lambda関数を作成する

CloudFrontへアクセスするためのCookieの値を返す関数を作成します。

  • 手順4で作成した文字列を設定します。
  • CORSに対応したレスポンスになるようにヘッダーを設定します。
exports.handler = async () => {
  const keyPair = 'XXXXXXXX';
  const policy = 'XXXXXXXX';
  const signature = 'XXXXXXXX';

  const json = {
    policy: policy,
    signature: signature,
    keyPair: keyPair
  };

  const response = {
    "statusCode": 200,
    "headers": {
        "Content-Type": 'application/json',
        "Access-Control-Allow-Headers": 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
        "Access-Control-Allow-Methods": "GET",
        "Access-Control-Allow-Origin": "*"
    },
    "body": JSON.stringify(json)
  };

  return response;
};

6. API Gatewayを作成する

API GatewayでCognitoで作成したユーザーのJWT Tokenを検証するためにオーソライザーを作成します。

API Gatewayのオーソライザーを作成する

  1. オーソライザーの名前を入力します。
  2. タイプでは、Cognitoを選択します。
  3. Cognitoユーザープールでは、今回使用する作成済みのユーザープール指定します。
  4. トークンのソースには、Authorizationと入力します。
    保存したら、テストしてみましょう。
    認証トークンに、認証できたユーザーの情報から取得できるdata.signInUserSession.idToken.jwtTokenを入力してテストします。
    ユーザー情報が出力されたらテストは成功です。

API Gatewayを作成する

  • GETメソッドの認可に先ほど作成したオーソライザーを指定します。
  • CROSを有効化します。
  • 手順5で作成したLambda関数と紐付けます。

おまけ

クライアント側のソースコードです。

cognitoの情報を記述したaws-export.js

const AwsConfig = {
  Auth: {
    // IDプールのID
    identityPoolId: 'us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx',
    // リージョン
    region: 'us-east-1',
    // プールID
    userPoolId: 'us-east-1_xxxxxxxx',
    // アプリクライアントID
    userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxx',
    mandatorySignIn: true,
  },
  Storage: {
    // 使用するS3バケット名
    bucket: 'xxxxxxxx',
    // リージョン
    region: 'us-east-1'
  }
}

export default AwsConfig;

public-index.js

import Amplify, { Auth } from 'aws-amplify';
import AwsConfig from './aws-exports.js';
Amplify.configure(AwsConfig);

const getEmail = () => 
  document.getElementById('email').value;

const getPassword = () => 
  document.getElementById('password').value;

const signUp = () => {
  const email = getEmail();
  const password = getPassword();

  Auth.signUp(email, password)
    .then(data => console.log(data))
    .catch(err => console.log(err));
}

const signIn = () => {
  const email = getEmail();
  const password = getPassword();

  Auth.signIn(email, password)
    .then(data => {
      const url = 'https://xxxxxxxx.execute-api.us-east-1.amazonaws.com/dev';
      const token = data.signInUserSession.idToken.jwtToken;

      fetch(url, {
        method: 'GET',
        headers: {
          'Authorization': token
        }
      })
      .then(res => res.json())
      .then(json => {
        document.cookie = `CloudFront-Key-Pair-Id=${json.keyPair}; path=/;`;
        document.cookie = `CloudFront-Policy=${json.policy}; path=/;`;
        document.cookie = `CloudFront-Signature=${json.signature}; path=/;`;
        document.cookie = `Email=${email}; path=/;`;
        location.href = '../private/private-index.html';
      })
      .catch(err => console.log(err));

    })
    .catch(err => console.log(err));
}

window.addEventListener('DOMContentLoaded', () => {
  const signUpButton = document.getElementById("signUpButton");
  signUpButton.addEventListener("click", () => {
    signUp();
  });

  const signInButton = document.getElementById("signInButton");
  signInButton.addEventListener("click", () => {
    signIn();
  });
});

private-index.js

import Amplify, { Auth } from 'aws-amplify';
import AwsConfig from './aws-exports.js';
Amplify.configure(AwsConfig);

const signOut = () => {
  console.log('Sign out Start.')

  // Cookieを削除します。
  document.cookie = "CloudFront-Key-Pair-Id=; path=/; max-age=0";
  document.cookie = "CloudFront-Policy=; path=/; max-age=0";
  document.cookie = "CloudFront-Signature=; path=/; max-age=0";
  document.cookie = "Email=; path=/; max-age=0";

  const url = '../public/public-index.html';

  Auth.signOut()
    .then(data => {
      console.log(data);
      location.href = url;
    })
    .catch(err => {
      console.log(err)
      location.href = url;
    });

}

window.addEventListener('DOMContentLoaded', () => {
  const signOutButton = document.getElementById("signOutButton");
  signOutButton.addEventListener("click", () => {
    signOut();
  });

  Auth.currentUserInfo()
    .then(user => console.log(user))
    .catch(err => console.log(err))
});

参考

Discussion