Single Page Applicationで静的コンテンツの配信にCloudFrontの署名付きCookieを利用してみた

2020.02.28

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。さかいです。

Single Page Application(以下 SPA)で画像ファイルなどの静的コンテンツを配信をどのように実装してますか? パブリックに公開するのであれば、あまり迷わなくて済むのですが、

  • コンテンツの配信は認証済みユーザのみアクセスできるようにしたい
  • サイズが大きいコンテンツやコンテンツ数も多いので大容量保存できるようにしたい
  • 特定のオブジェクトは特定のユーザだけがアクセスできるようにしたい

などの要件があると、どのように実装できるだろうか?と迷うこともあるかと思います。

そんな時の実装案の1つとして、S3 + CloudFrontで署名付きCookieを利用してコンテンツの配信をやってみます。

構成イメージ

今回作成するイメージは以下の通りとなります。

構成図

SPAからAPIを叩いてデータをやり取りするようなアプリケーションとなります。SPAは適切なタイミングでAPIを実行し、画像取得用のCookieを取得します。(取得する際はユーザの認証やアクセス可能な権限を持ったユーザであることを確認する)
このCookieを利用して、画像を取得してみます。

ドメイン構成

今回は検証用のドメインとして、example.comと仮定して説明させていただきます。

  • SPA
    • example.com
  • API
    • api.example.com
  • 画像配信
    • images.example.com

前提条件

画像配信の設定

画像を配信するために、S3、CloudFrontを設定します。バケットを作成し、サンプル画像をアップロードしておきます。ここではバケットやオブジェクトの公開権限は付与しません。

続いて、CloudFrontを設定します。Originは上記で作成したバケットを指定します。また、オリジンアクセスアイデンティティを設定して、画像配信バケットへのアクセスを制限します。

設定後、画像配信バケットのバケットポリシーを確認するとオリジンアクセスアイデンティティでアクセス制御が設定されていることを確認できます。

アクセスを確認

ここで、設定したCloudFrontから画像ファイルへアクセスできるか確認しておきます。

CloudFrontから、S3バケットに保存されている画像にアクセスできることを確認できましたー。

署名付きCookieの有効化

このままだと画像へのアクセスが誰でもできる状態ですので、署名付きCookieが無いとアクセスできないようにCloudFrontを設定します。 上記で作成したDistributionsのIDをクリックし、Behaviorsタブで、既存のBehaviorを選択し、Editをクリックします。 編集画面で、Restrict Viewer AccessYesを選択し、保存します。

設定後、画像ファイルへアクセスすると署名付きCookieが無いため、アクセスができないことを確認できます。

ここからCloudFrontの画像ファイルにアクセスするために署名付きCookieを取得する処理を作成していきます。 署名付きCookieを生成するには、CloudFrontのキーペアが必要となり、作成にはroot認証情報が必要となります。作成方法については、以下をご確認ください。

AWS マネジメントコンソールで CloudFront キーペアを作成するには

APIを実装

Secrets Managerに秘密鍵を保存

CloudFrontのキーペアのキーペアIDと秘密鍵をSecrets Managerに保存します。

キーペアIDはCloudFrontキーペアを作成した時に、ダウンロードしたファイルの名前に設定されている値を設定します。
pk-XXXXX.pemのXXXXX部分
また、秘密鍵保存の際は、改行コードがスペースに置き換えられてしまうので、-----BEGIN RSA PRIVATE KEY-----の後、-----END RSA PRIVATE KEY-----の前に改行コード\nを入れて保存するとプログラムからうまく取得できました。

Cookieを作成するLambda関数を作成

実際に署名付きCookieを作成する処理をLambdaで実装します。
今回は

  • ランタイム
    • Node.js 12.x
  • 実行ロール
    • SecretsManagerReadWriteポリシーをアタッチ(実際は読み込みのみなので書き込み権限はなくても大丈夫です)
  • CORS対応
    • Lambda プロキシ統合を使用するのでレスポンスにCORSに必要なヘッダーを設定

で関数を作成しました。

const SecretsManager = require("aws-sdk/clients/secretsmanager");
const CloudFront = require("aws-sdk/clients/cloudfront");

// Sercret Managerに設定したシークレットの名前
const SECRET_ID = [シークレットの名前];
// ドメイン名
const DOMAIN = [ドメイン名];

exports.handler = async event => {
  const client = new SecretsManager();
  const data = await client.getSecretValue({ SecretId: SECRET_ID }).promise();
  const secretJson = JSON.parse(data.SecretString);
  // カスタムポリシーの設定
  // アクセスを許可するリソース(許可するパスなど)と有効期間を設定する
  const policy = {
    Statement: [
      {
        Resource: `https://images.${DOMAIN}/*`,
        Condition: {
          DateLessThan: {
            "AWS:EpochTime": Math.floor(Date.now() / 1000) + 60 * 60 * 2 // この設定だと2時間
          }
        }
      }
    ]
  };

  const signer = new CloudFront.Signer(
    secretJson.KEY_PAIR_ID,
    secretJson.PRIVATE_KEY
  );
  const signedCookie = signer.getSignedCookie({
    policy: JSON.stringify(policy)
  });
  const cookies = [
    `CloudFront-Policy=${signedCookie["CloudFront-Policy"]};Domain=${DOMAIN};path=/;Secure;`,
    `CloudFront-Signature=${signedCookie["CloudFront-Signature"]};Domain=${DOMAIN};path=/;Secure;`,
    `CloudFront-Key-Pair-Id=${signedCookie["CloudFront-Key-Pair-Id"]};Domain=${DOMAIN};path=/;Secure;`
  ];

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: "success!"
    }),
    headers:{
      "Access-Control-Allow-Origin": `https://${DOMAIN}`,
      "Access-Control-Allow-Credentials": "true",
      "Access-Control-Allow-Headers": "*"
    },
    multiValueHeaders: {
      "Set-Cookie": cookies
    }
  };
};

今回はカスタムポリシーを設定して、署名付きCookieを作成しました。カスタムポリシーについては、以下を参照してください。
カスタムポリシーを使用した署名付き Cookie の設定

API Gatewayを作成

上記で作成した、Lambda関数をAPI Gatewayから実行できるように、API Gatewayを設定し、デプロイします。

  • リソース名
    • cookies
  • メソッド
    • GET
  • 統合タイプ
    • Lambda関数
  • Lambda プロキシ統合の使用
  • Lambda関数
    • 上記で作成したLambda関数を設定

APIにドメインを設定

カスタムドメインの設定とRoute 53の設定し、api.example.comでアクセスできるように設定しておきます。

APIを実行

ここまで作成した署名付きCookieを作成するAPIをブラウザから実行してみます。

画像にもアクセスできるようになりましたー。署名付きCookieが設定されていますね。

SPAから画像を取得

ここまでで、画像配信の設定、Cookieの取得および設定する処理の準備ができましたので、簡単なSPAを作成し、画像が取得できるか試してみます。 作成したソース一式は、以下にて共有しています。
SPAのソース一式

Cookie取得APIの呼び出し

API呼び出し部分の実装は、fetchにて以下のように実装するとうまくCookieがセットされました。また、CORSでCookieのやりとりをする場合は、credentialsincludeまたはsame-originを指定する必要があります。

  getCookie() {

    fetch(`${[ここはAPIのURL]}/cookies`, {
      mode: "cors",
      credentials: "include"
    }).then(() => {
      alert("getCookie success!!!");
    });
  }

あれ?Cookieの書き込みがエラーになる・・・

ブラウザからアクセスするのと、Ajaxでリクエストを送る場合で動きが変わります。具体的には、https://example.comからhttps://api.example.comにリクエストを送る場合は、CORSの設定が必要になります。 レスポンスヘッダーに"Access-Control-Allow-Origin": "*"を指定すると、どのドメインからでもアクセスすることができるのですが、*を指定した状態で、Cookieを取得しようとすると以下のようにエラーとなります。

ですので、CORSのレスポンスヘッダーの設定は、

  • "Access-Control-Allow-Origin": "https://[ドメイン名]"
  • "Access-Control-Allow-Credentials": "true"
  • "Access-Control-Allow-Headers": "*"
    • 必要なヘッダーが決まっているならここで設定する

のように設定しておく必要があります。

改めてCookieの取得

今度はCookieがセットされましたー。

画像を表示してみる

取得したCookieを使って、画像ファイルを表示してみます。

設定されたCookieを付与して画像を取得しているので、画像を表示することができていますね。サンプルとして画像を6個表示していますが、すべての画像取得に同じCookieを利用しています。一度Cookieを取得すれば、Cookieが無効になるまで(ポリシーの有効期限もあります)再取得する必要がありません。取得する画像が多いと署名付きURLを生成して利用する場合に比べてメリットとなりそうですね。

さいごに

少し長くなりましたが、SPAで静的コンテンツの配信を制御する1つの方法として、紹介させていただきました。紹介した内容がどなたかのお役に立てば幸いです。

参考