Node.jsでAmazon CloudFrontの署名付きURLを生成する


## やりたいこと

- 認証が必要なWebアプリケーションをAWS上に構築する
  - *Amazon S3*で画像ファイルを管理する
  - 画像ファイルには認証済みユーザーのみがアクセス可能
  - 独自URLで公開する
  - 当然SSL化する

いろいろとググったところ、*Amazon CloudFront* の *署名付きURL (signed url)* を使うと目的を達成できそう。
環境整備が面倒くさかったので手順を整理しておきます。

*この記事では 署名付きURL の作成方法について取り上げます。Webアプリケーションの構築については範囲外です*

## 手順

### リソース管理用のS3バケットを準備する

画像ファイルを格納するS3のバケットを作成します。
今回は `cloudfront-private-content` という名前にしました。

テストのために `cat.jpg` と `dog.jpg` をバケットにアップロードします。

### CloudFrontでS3バケットを公開設定する

CloudFrontを迂回してアクセスできないように設定します。
*Step 2: Create distribution* の画面で以下の設定を行います。

#### Origin Settings

| 項目 | 設定値 | 説明 |
| ---- | ---- | --- |
| *Origin Domain Name* | 作成したS3バケットを指定 | バケットのWebサイトホスティング設定は不要 |
| *Restrict Bucket Access* | Yes | バケットへのアクセスをCloudFront経由に制限する |
| *Origin Access Identity* | Create a New Identity | バケットアクセスのための認証アカウントを作成 |
| *Grant Read Permissions on Bucket* | Yes, Update Bucket Policy | バケットのアクセス権限を変更し、CloudFrontによる読み取り権限を付与する |

#### Default Cache Behavior Settings

| 項目 | 設定値 | 説明 |
| ---- | ---- | --- |
| *Viewer Protocol Policy* | Redirect HTTP to HTTPS | HTTPアクセスされたらHTTPSにリダイレクト |
| *Restrict Viewer Access (Use Signed URLs or Signed Cookies)* | Yes | 署名付きURLあるいはCookieによるアクセスのみに制限する |

コンソールの *CloudFront Distributions* にて status が `In Progress` から `Deployed` に変わるまで結構時間がかかります。
ゆっくりコーヒーを飲む時間くらいはかかるので、余裕を見て作業しましょう。

#### 2018-11-12追記

公開当初、この構成のキモである **Restrict Viewer Access** の設定が漏れてました。
今日、自分でこの記事を見て、「なんでこの設定で署名付きURL以外のアクセスが制限されるんだ？」って悩んでしまいました...


### CloudFrontへ独自ドメインでアクセスする

*Route 53* で先程作成した *CloudFront Distribution* へのaliasを作成します。

まず、CloudFrontのコンソールで *Domain Name* をコピーしておきます。
`xxxxxxxxxxxxx.cloudfront.net`

つづいて、Route53で *Aレコード* を追加します。

| 項目 | 設定値 |
| ---- | ---- |
| *Name* | `private-content`.example.com |
| *Type* | A - IPv4 address |
| *Alias* | Yes |
| *Alias Target* | `xxxxxxxxxxxxx.cloudfront.net` |

ふたたび CloudFrontのコンソールに戻り、*Alternate Domain Names (CNAMEs)* を更新します。

リストから作成したDistributionを選択し、`Distribution Settings`ボタンをクリックします。
`General`タブの`Edit`ボタンをクリックします。
*Alternate Domain Names (CNAMEs)* にRoute53で登録したAレコードのNameを入力します。
`Yes, Edit`ボタンをクリックし、変更を保存します。


### 独自ドメインをSSL化する

また `Distribution Settings` > `General` > `Edit`ボタンをクリックします。
*SSL Certificate* で `Custom SSL Certificate (example.com):` を選択したいところですが、まだアクティブになっていないと思います。（選択可能な証明書があればアクティブになる）
*AWS Certificate Manager* で証明書を作成し登録します。

`Request or Import a Certificate with ACM`ボタンをクリックします。
*AWS Certificate Manager*が開きます。このときリージョンが *バージニア北部* となっていることを確認してください。（そうでなければ変更する）
ドメイン名は `*.example.com` として続行します。
今回、ドメインの管理もRoute53で行っているのでCNAMEの登録による確認で証明書が発行されます。

ここでも検証が完了するまで少し時間がかかりますが、*状況* が *発行済み* になるまで何度かリロードしてください。

（私の環境では、以降のCloudFrontのコンソールでなかなか証明書が一覧に表示されず、しばらく悩みました。
経験上、AWSコンソールでの作業はひとつづつ確実にこなすのが良いです。）

CloudFrontのコンソールに戻り、*SSL Certificate* で `Custom SSL Certificate (example.com):` が選択可能であればチェックしてください。
(私の環境では一旦Editをキャンセルし、ブラウザのリロードを行い、再度Edit画面に戻ると選択可能になっていました。)

*Custom SSL Certificate (example.com):* のテキストボックスにフォーカスを合わせると、*AWS Certificate Manager*で発行した証明書が表示されますので、選択してください。

`Yes, Edit`ボタンをクリックし、変更を保存します。


### キーペアの作成

署名付きURLを生成するための公開鍵、秘密鍵、アクセスキーIDを取得します。

AWSコンソールのヘッダー右上、ユーザー名の部分をクリックして *セキュリティ認証情報* を選択します。
ダイアログが表示される場合は`Continue to Security Credentials`ボタンをクリックして続行します。

*CloudFrontのキーペア* にて `新しいキーペアの作成` をクリックし、公開鍵と秘密鍵をダウンロードします。 
また、*CloudFrontのキーペア* に `アクセスキーID` が表示されるので、これもコピーしておきます。


### Node.jsでAmazon CloudFrontの署名付きURLを生成する

やっと本題。
今回は署名付きURLを生成する機能だけを実装します。

適当にNode.jsのプロジェクトフォルダを作成し、AWS-SDKとその他のパッケージをインストールします。
(その他のパッケージは署名付きURLの生成に直接関与するものではありません。AWS-SDKだけが必須です。)

```sh
$ mkdir cloudfront-secret-content
$ cd cloudfront-secret-content
$ npm init -y
$ npm install --save aws-sdk dotenv fs-extra moment
```

以下のように、コンソールに生成したURLを出力するコードを作成します。

```js:index.js
require('dotenv').config();
const AWS = require('aws-sdk');
const fs = require('fs-extra');
const moment = require('moment');

// 対象となるリソースのURL
const target = 'https://private-content.example.com/cat.jpg';

/**
 * AWS.CloudFront.Signer.getSignedUrlを呼び出す。
 *
 * Class: AWS.CloudFront.Signer — AWS SDK for JavaScript
 * https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront/Signer.html
 * @param {String} keypairId
 * @param {String} privateKey
 * @param {Object} options 
 */
function getSignedUrlAsync(keypairId, privateKey, options) {
  return new Promise((resolve, reject) => {
    // Signerインスタンスを生成
    const signer = new AWS.CloudFront.Signer(keypairId, privateKey);
    // URL生成
    signer.getSignedUrl(options, (err, url) => {
      if (err) {
        reject(err);
      }
      resolve(url);
    });
  });
}

async function main() {
  // private keyを読み込む
  const privateKey = await fs.readFile(process.env.PRIVATE_KEY_FILE, { encoding: 'utf-8' });

  // 期限を設定
  // 現在日時から1日後まで有効とする
  const expires = moment.utc().add(1, 'days').unix();

  // URL生成
  const url = await getSignedUrlAsync(
    // キーペアのID
    // AWSコンソールの以下の場所で確認可能
    // セキュリティ認証情報 > CloudFront のキーペア > アクセスキーID
    process.env.KEYPAIR_ID,
    // 秘密鍵を渡す
    privateKey,
    {
      // 対象となるCloudFrontのURL
      url: target,
      // 生成されるURLの期限 (UTCのunixtime)
      expires
    }
  );
  
  console.log(url);
}

main().then(() => {
  console.log('done.');
}).catch((err) => {
  console.error(err);
});
```

秘密鍵の保存場所とアクセスキーIDを `.env` ファイルに記述します。

```conf:.env
KEYPAIR_ID=xxxxxxxxxxxxxxxxxxxx
PRIVATE_KEY_FILE=./secret/pk-xxxxxxxxxxxxxxxxxxxx.pem
```


実行すると以下のような結果が返ります。

```sh
$ node index.js
https://private-content.example.com/cat.jpg?Expires=1540535260&Key-Pair-Id=XXXXXXXXXXXXXXXXXXXX&Signature=.....
done.
```

返されたURLにブラウザからアクセスし、画像が表示されることを確認してください。
また、QueryString部分をカットして `403` が返されること、`cat.jpg`を`dog.jpg` に変更してもアクセスできないことを確認します。

---

## 参考

- [署名付き URL の使用 - Amazon CloudFront](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html)
- [Class: AWS.CloudFront.Signer — AWS SDK for JavaScript](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront/Signer.html)
- [CloudFront+S3で署名付きURLでプライベートコンテンツを配信する ｜ DevelopersIO](https://dev.classmethod.jp/cloud/aws/cf-s3-deliveries-use-signurl/)