AWS S3とCloudFrontで特定ユーザーにだけページを配信する

S3で配信するウェブサイトの閲覧を特定ユーザーに限定したいということについてあれこれ試したときのメモです。たとえば以下のようなことをやりたいときに有用かもしれません。

  • あるサービスにログインしているユーザーにだけ見せるコンテンツを配信する
  • プレビュー用のアプリケーションをS3に設置して特定のメンバーにのみ閲覧できるようにする

作戦

system

なるべく安上がりに機能を実現するために、以下の機能を用いることとしました。

  • CloudFrontの署名付きCookie 機能によって、認証フローを経て払いだされたCookieの有無によってアクセスを許可する
  • Lambda@EdgeによってCloudFrontの特定パスにリソースのアクセス認可を行うエンドポイントを追加する
    • 認証結果に基づき署名付きクッキーを発行する
    • この関数は署名付きクッキー存在しないあるいは無効なときにしか呼ばれない
  • Sign In With Google に認証を任せる

まずは、CloudFrontで署名付きCookieを使えるようにして、アクセス制限を試してみます。

CloudFrontの署名付きCookieによるアクセス制限

CloudFrontには、アクセスを制御するポリシーおよび、このポリシーに対する署名をクッキーに焼くことで、このアクセスポリシーを適用させることができる機能があります。 ref: 署名付き Cookie の使用

以降は、既にコンテンツ配信用のCloudFrontやオリジンサーバのセットアップが完了していることを前提に説明を続けます。

まずはキーペアを作ります。AWSのドキュメント に記載の方法をそのまま実行します。

# キーを作成
openssl genrsa -out private_key.pem 2048

# キーからパブリックキーを作成
openssl rsa -pubout -in private_key.pem -out public_key.pem

公開鍵を登録します。作成した public_key.pem をCloudFrontのWebコンソールから登録します。

key-registration

続いてアクセス制限を設定します。コンテンツ配信のためのビヘイビアを選択します。

setup1

以下のように設定します。

  • 「ビューワーのアクセスを制限する」を「Yes」にする
  • 「信頼された認可タイプ」を「Trusted key groups」に設定し、「キーグループを追加」を押して先ほど作ったキーペアのパブリックキーを含むキーグループを追加
  • 作成したキーグループを「キーグループを選択」ドロップダウンリストから選択

key-registration

これで、署名付きクッキーが付与された場合のみ、クッキーに含まれるポリシーに従ってアクセスが許可されます。

例えば以下のようなNode.jsスクリプトを記述して出力されるログをブラウザのコンソールに貼り付けてクッキーを焼くと、コンテンツにアクセスできることが確認できます。

const crypto = require('crypto')
const fs = require('fs')
const path = require('path')

const keyPairId = 'xxxxx' // パブリックキーIDを設定する
const expires = Math.floor(new Date().getTime() / 1000) + 100000

// 適用させるポリシー
const policy = {
  "Statement": [
    {
      "Resource": "https://xxxxx.cloudfront.net/*", // 配信サーバのドメインに置き換える。この設定の場合はすべてのリソースにアクセス可能
      "Condition": {
        "DateLessThan": {
          "AWS:EpochTime": expires
        }
      }
    }
  ]
}

// ポリシーの署名
const signer = crypto.createSign('RSA-SHA1');
signer.update(JSON.stringify(policy));
const privateKey = fs.readFileSync(path.resolve('./private_key.pem')) // 作成したキーペアのうち秘密鍵を設定

// 署名を行い、クッキーに使える文字列の置換
// ref: https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-canned-policy.html
const signed = signer.sign(privateKey, 'base64')
                     .replaceAll('+', '-')
                     .replaceAll('=', '_')
                     .replaceAll('/', '~')

// ポリシーのBase64エンコード
const policyEncoded = Buffer.from(JSON.stringify(policy)).toString('base64')

// 以下のクッキーをブラウザに焼くことでコンテンツへのアクセスが可能
console.log(`document.cookie = 'CloudFront-Policy=${policyEncoded};secure';`)
console.log(`document.cookie = 'CloudFront-Signature=${signed};secure';`)
console.log(`document.cookie = 'CloudFront-Key-Pair-Id=${keyPairId};secure';`)

ここまでの試行で署名付きクッキーによってコンテンツへのアクセス許可を制御できることが分かりました。加えて以下を実装すれば特定ユーザーのみアクセス許可するという要件を実現できそうです。

  • 認証を行う
  • 認証結果に基づいてポリシーを制御することでアクセス権限を制御する
  • クッキーに焼くことでコンテンツへのアクセスを許可する(あるいはクッキーに焼かないことで許可しない)

既に認証を行ったりクッキーを焼くことのできるシステムが存在する場合は、そのシステムで上記のことを行えばよさそうです(ただし、クッキーを利用するのでこれらのシステムのドメインについて考慮する必要があります)。今回は、それらのシステムが存在せず、新たにAWS上にシステムを構築することを前提に以降の説明を行います。

サンプルアプリを使ったアクセス制限

Sign In With Googleの設定

今回は認証を外部ID基盤(Sign In With Google)に任せるので、Sign In With Googleを使うためのOAuthアプリを作成します。Google Cloud PlatformのコンソールからOAuthクライアントIDを選択して作成します。

google-auth

  • 承認済みのJavaScript生成元には、CloudFrontのURLを設定します(ex: https://xxxxxxxx.cloudfront.net)
  • 承認済みのリダイレクトURIには、CloudFrontのURLに加えてパスに /auth/callback を付与します(ex: https://xxxxxxxx.cloudfront.net/auth/callback)

認証ページに設置されたサインインボタンを押し、Googleアカウントでログインすると、アカウント情報が記されたIDトークンが「リダイレクトURI」宛にPOSTされます。このリダイレクトURI宛リクエストをLambda@Edgeを用いて処理し、必要に応じてアクセス許可を付与します。

サンプルアプリのセットアップとデプロイ

Sign In With Googleに認証を任せ、認証情報に基づきリソースへのアクセスを許可する役割をLambda@Edgeで実現するためのアプリケーションを用意しました。これをデプロイしてみましょう。

piyoppi/cloudfront-private-access (GitHub)

注意:上記のソースコードは現時点ではあくまで「サンプルコード」です。一部は調整中で、(私自身がこのアプリケーションを使うために作っているので)今後テストコードなどを拡充してアプリケーションの信頼性を向上させる予定ですが、あくまで自己責任としてご利用ください。

上記リポジトリをクローンします。

git clone git@github.com:piyoppi/cloudfront-private-access.git

上記リポジトリのルートディレクトリで以下のコマンドを実行します(下記コマンドの[your-project-name]の部分を任意の文字列に置き換えます。この文字列はプロジェクトに必要なリソース(S3バケット名やLambda Functionの名称)に利用されます)。

AWS SAMテンプレートファイル template.yml と Lambda@Edge Function用の設定ファイル functions/.generated/configModule.js が生成されます。

./bin/setup.sh -b [your-project-name]

続いてLambda Functionをビルドします。予め AWS SAM CLIをインストールしておく必要があります。

sam build --beta-features

以下のコマンドでサンプルアプリに含まれるLambda Function及び、動作に必要なS3バケットをデプロイできます。 初回デプロイ時のみ --guided オプションが必要です。この際、デプロイ先のRegionを聞かれますが、Lambda@Edgeは対象のLambda Functionがus-east-1リージョンに存在する必要があるので、「us-east-1」を設定します。

sam deploy --guided

先ほどのサンプルコードに示した通り、署名には秘密鍵が必要です。デプロイされたLambda Functionで署名するために必要な秘密鍵をS3にアップロードします。(このコマンドを実行するには aws-cli をインストールし、認証を通しておく必要があります)

./bin/upload-key.sh /path/to/private_key.pem

設定を記述します。 config/config.json を開いて編集します。

{
  "keyPairId": "XXXXXXX",                               // Cloudfront キーID
  "cloudFrontUrl": "https://xxxxxxxxx.cloudfront.net",  // Cloudfront URL
  "authIssuerClientId": "xxxxxxxx",                     // Sign In With Googleで利用するOAuthクライアントID
  "uid": [                                              // アクセスを許可するGoogleアカウントのメールアドレス
    "account@example.com"
  ]
}

記述したら以下のコマンドを用いてコンフィグファイルをS3にアップロードします。(このコマンドを実行するには jq コマンドを予めインストールしておく必要があります)

./bin/upload-config.sh

これでサンプルアプリのセットアップは完了しました。これまでの操作で以下のことが行われました。

  • 以下のS3バケットの作成
    • [your-project-name]-config(ログインに必要なコンフィグファイルと鍵ファイルを格納するバケット)
    • [your-project-name]-staticpage(ログインページを格納するバケット)
  • [your-project-name]-SignInHandler Lambda Function(ログイン処理を行うLambda Function) のデプロイ
  • [your-project-name]-config へのファイル設置
  • [your-project-name]-staticpage へのファイル設置

先ほど登場した構成図でいうところの下図赤枠部分を準備したことになります。AWSのWebコンソールやAWS CLIでデプロイされたリソースやコンテンツを確認してみると良いと思います。

system2

CloudFrontのセットアップを行う

デプロイしたサンプルアプリとCloudFrontの連携を行います。

まず、認証画面を提供するオリジンを追加します。先ほどデプロイして作成されたS3バケット「[your-project-name]-staticpage」を「オリジンドメイン」に設定します。 必要に応じて、S3 バケットアクセスの項目を設定します(筆者はOAIを使用することとし、新しいOAIを作成したうえで、バケットポリシーは自動で更新されるように設定しました。このようにすることで、バケットのパブリックアクセスを禁止した上でCloudFrontでコンテンツを配信できます)

setup3

[your-project-name]-staticpage バケットには auth/index.html ファイルが配置されており、認証が必要な場面でこのHTMLを返却するようにします。このためのビヘイビアを作成します。

setup4

  • パスパターンは 「/auth/*」
  • オリジンとオリジングループは「[your-project-name]-staticpage」
  • ビューワプロトコルポリシーは「HTTPS Only」 か 「Redirect HTTP to HTTPS」

これで [your-project-name]-staticpage に設置された認証画面を配信する準備ができました。

認証が必要な場合は /auth/index.html を表示したいので、カスタムエラーレスポンスを作成します。

setup5

  • HTTPエラーコードは「403: Forbidden」
  • エラーレスポンスをカスタマイズするを「はい」
  • レスポンスページのパスを「/auth/index.html」

これで、認証が必要な場合(=403 Forbiddenを返却するとき)に [your-project-name]-staticpage に設置された認証画面が表示されるようになりました。

この時点で、適当なページにアクセスしようとすると以下のような画面に誘導されると思います。

setup6

続いてSign In With Googleから払い出されるIDトークンを受け取るエンドポイントを用意します。先ほどと同じ要領でビヘイビアを一つ作成します。

  • パスパターンを「/auth/callback」
  • オリジンとオリジングループは「[your-project-name]-staticpage」
  • ビューワプロトコルポリシーは「HTTPS Only」 か 「Redirect HTTP to HTTPS」
  • 許可されたHTTPメソッドは「GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE」
    • POSTメソッドで送信される Sign In With Google からのレスポンスを受け取るための設定

加えて、先ほどデプロイされたLambda FunctionのARNを「ビューワーリクエスト」に設定します。このときLambda Functionのバージョンも指定する必要があります。たとえば、ARNが arn:aws:lambda:us-east-1:00001:function:[your-project-name]-SignInHandler の場合は、Lambda Functionをはじめてデプロイしたときは arn:aws:lambda:us-east-1:00001:function:[your-project-name]-SignInHandler:1 のように記述します。 バージョンやARNはLambdaのWebコンソールなどで確認します。

setup2

これで、Sign In With Google が返却するIDトークンをLambda Function([your-project-name]-SignInHandler)が受け取れるようになりました

ここまでの手順で、ビヘイビアが3つ存在する状態になっています。パスパターンの優先順位を以下の画像のように調整します。

setup7

まだ設定していなければ、ディストリビューションのデフォルトルートオブジェクトを設定します。

setup8

これで準備完了です。この状態で試しにコンテンツにアクセスしようとするとログイン画面が表示されます。config/config.json で指定した "uid" に含まれるメールアドレスを持つアカウントでログインした場合のみページを閲覧できていれば成功です。

サンプルアプリで行っていること

サンプルアプリのうち、認証結果に基づきアクセス許可を与えるLambda Functionの中身は「CloudFrontの署名付きCookieによるアクセス制限」の項で示したサンプルプログラムとほぼ同じです。

認証結果(=IDトークン)の検証は、Sign In With Googleに記述された方法に基づき、google-auth-library を使っています。

その他のソースコードは基本的にAWS SAMに関するもの(AWSサーバレスアプリケーションを管理するための設定ファイルなど)ですから、かなりシンプルな構成でお手軽に、配信されるページに対するアクセス制限を認証結果に基づき提供できていそうかなと思っています。

クラウドのリソースを使えばお手軽にこのようなことができるのだなという記事でした。ではでは~

このカウンタは @piyoppi/counter-tools を使っています。

クリックすると匿名でいいねできます。