CloudFront 署名付きURLと署名付きCookieをおさらいしてPythonで試してみた

CloudFrontの署名付きURLと署名付きCookie、定義を整理して、Pythonで試してみました。
2021.04.22

Guten Abend, ベルリンの伊藤です。

SAPの勉強中、Trusted Signer, OAI なんてキーワードが出てきて、結構理解が曖昧だったことに気付いたので一からおさらいしました。

ドキュメントを元に要約した各設定の解釈と、Pythonによるやってみたを載せます。

導入

S3などオリジンに配置してあるコンテンツをCloudFront経由で配信します。主な目的は配信の高速化ですね。毎度オリジンまで取りに行かなくても、世界中にあるCloudFrontエッジロケーションにより、ユーザに近いエッジから配信されます。

そしてプライベートなコンテンツを制限を設けて配信したい場合には、本記事で取り扱う 署名付き URL/Cookie を使うことができます。

これは、誰でもかれでもCloudFrontを経由したらコンテンツを入手できないよう、次の図でいう【ユーザからCloudFrontへの矢印】を制御する時に使います。また、オリジンへ直接アクセスできるままになっていては元も子もないので、オリジンへのアクセスは以下のいずれかで制御します。

OAI は下記ブログでも紹介されています。

【初心者向け】CloudFront経由でS3のファイルを見る【やってみた】

署名付きURLと署名付きCookie

  • 署名付き〜 (Signed~)
    • URL: 個別のファイルを制限したい場合、ユーザがCookieをサポートしないクライアントを使用する場合に(ファイルごとにランダムな文字列(=署名)を含むURLを発行してそこにアクセスしてもらう)
    • Cookie: 特定条件の複数ファイルを制限したい場合、現在のURLを変えたくない場合に(事前にリクエストへのレスポンスで特別なヘッダを返却しておき、その値(=署名)をヘッダに含めることでアクセスしてもらう)
  • 設けられる制限
    • 既定ポリシー: アクセスの有効期限
    • カスタムポリシー: アクセスの有効期間、ユーザのアクセス元IP
  • 設定区分
    • CF ディストリビューションの Behavior (パスごとに設定分けていれば、パスごとに)
  • 署名者: 署名付きURL/Cookieの作成に使う
    • 信頼されたキーグループ(Trusted Key Group): キー自作・AWS推奨
    • CFキーペアを持つAWSアカウント(Trusted Signer): root作業につき非推奨

署名者(signer)については英語名が少しややこしいのですが、CFコンソール表記によると、2つ目のAWSアカウントの方を"Trusted Signer"と呼ぶみたいです。

資格勉強でサンプル問題文に "OAI" と "Trusted Signer" が出てきて混同していたのですが、OAIはS3アクセス制限のために使うCF特別ユーザTrusted SignerはでCFキーペアを使って署名付き~を発行するAWSアカウントと覚えておき、使いどこ(冒頭図の矢印で右か左か)を押さえておけば良さそうですね。

検証

署名者の準備

上述の通り Trusted Signer はルートアカウントによる作業が必要なため、今では推奨されていません。今回も信頼されたキーグループでやっていきます。大まかな手順は以下の通りですが、ドキュメントも十分に詳しいのと、↓後述のZennの記事がすべてを物語っているので私はこれだけに留めます。

  1. ローカルで公開鍵と秘密鍵のキーペアを作る
  2. 公開鍵をCloudFrontへアップロード(*このキーペアIDをURL発行時に使います)
  3. CloudFrontでキーグループを作成し、アップロードした公開鍵を追加
  4. 対象ディストリビューション→Behaviorを編集し、作成したキーグループを指定する

署名付きURLの発行

署名付きURLのおさらい:
個別のファイルを制限したい場合、ユーザがCookieをサポートしないクライアントを使用する場合に(ファイルごとにランダムな文字列(=署名)を含むURLを発行してそこにアクセスしてもらう)

もちろん既に検証している方々がいて、とても分かりやすいですので、参考にしてください!

公式の各種言語によるサンプルはドキュメントにリンクがあります。以下はほぼPythonのサンプルの通りです。

"""
pip install cryptography botocore
"""
from datetime import datetime, timedelta

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from botocore.signers import CloudFrontSigner

def rsa_signer(message):
    with open('./private_key.pem', 'rb') as key_file: #Specify the pem file
        private_key = serialization.load_pem_private_key(
            key_file.read(),
            password=None,
            backend=default_backend()
        )
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())

key_id = 'KPSXXXXXXXXXX' #Specify the public key ID
url = 'https://d1xxxxxxxxxxxx.cloudfront.net/' #Specify the distribution URL
file = 'Hoya-king.png'
expire_date = datetime.utcnow() + timedelta(minutes=30)  #Specify the expiry date

cloudfront_signer = CloudFrontSigner(key_id, rsa_signer)

signed_url = cloudfront_signer.generate_presigned_url(
    url + file, date_less_than=expire_date)
print(signed_url)

このスクリプトを実行すると、署名付きURLが返却されます。URLに含まれるエポック時間 1619021278 を確認してみると、ローカル時間で 2021-04-21 6:07:58 PM と、指定した通り30分後であることが確認できます。

21-04-21 17:37:53 % python test.py 
https://d1xxxxxxxxxxxx.cloudfront.net/Hoya-king.png?Expires=1619021278&Signature=19z5Hxxxxxxxx&Key-Pair-Id=KPSXXXXXXXXXX

有効期限内では、URLを開くと画像へアクセスすることができます。

なお、署名なしのURL(https://d1xxxxxxxxxxxx.cloudfront.net/Hoya-king.png)へアクセスすると MissingKey エラー、他に以下のパターンでは AccessDenied エラーとなります。

  • S3オリジンURL(https://bucket-name.s3.eu-central-1.amazonaws.com/Hoya-king.png)
  • 有効期限切れの署名付きURL
  • 署名付きURLを発行し直した場合、以前の署名付きURL

署名付きCookie

署名付きCookieのおさらい:
特定条件の複数ファイルを制限したい場合、現在のURLを変えたくない場合に(事前にリクエストへのレスポンスで特別なヘッダを返却しておき、その値(=署名)をヘッダに含めることでアクセスしてもらう)

個人的に、URLの方はだいたい思ってた通りという感じだったんですが、Cookieの方の理解がだいぶ曖昧だったので、もう少し詳しく。ドキュメントを要約すると以下のような仕組みとなっています。

  1. コンテンツにアクセスするユーザは、特定の条件を満たす(ウェブサイトにサインインしてコンテンツを購入するなど)
  2. 条件を満たしたユーザに対し、アプリケーションからのレスポンスに Set-Cookie ヘッダ(名前と値のペア)を含める(購入完了時、購入完了したユーザのログイン時などで)
  3. ユーザはブラウザ等でコンテンツにアクセスする際、受け取った名前と値ペア(=署名付きCookie)をリクエストのヘッダに含める
  4. リクエストを受け取ったCFは署名付きCookieが有効であることを確認し、アクセスを許可する

この Set-Cookie ヘッダは、ポリシー(有効期限やソースIP)、署名、キーペアIDの3つが必要です。

コード

こちらも、公式の各種言語によるサンプルはドキュメントにリンクがありますが、Pythonはありませんでした。

今回はこちらのGitHubを主に参考にさせていただきました。rsa_signer等の骨子は公式に合わせる形で置き換え、確認用 curl を出力する generate_curl_cmdこちらのGitHubを参考にさせていただきました。

"""
pip install cryptography botocore requests
"""
from datetime import datetime, timedelta

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from botocore.signers import CloudFrontSigner

import requests

def rsa_signer(message):
    with open('./private_key.pem', 'rb') as key_file: #Specify the pem file
        private_key = serialization.load_pem_private_key(
            key_file.read(),
            password=None,
            backend=default_backend()
        )
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())

def generate_signed_cookies(cf_signer, key_id, url, expire_date):
    policy = cf_signer.build_policy(url, expire_date).encode('utf8')
    policy_64 = cf_signer._url_b64encode(policy).decode('utf8')

    signature = rsa_signer(policy)
    signature_64 = cf_signer._url_b64encode(signature).decode('utf8')
    return {
        "CloudFront-Policy": policy_64,
        "CloudFront-Signature": signature_64,
        "CloudFront-Key-Pair-Id": key_id,
    }

def generate_curl_cmd(url, cookies):
    curl_cmd = "curl -D -"
    for k, v in cookies.items():
        curl_cmd += " -H 'Cookie: {}={}'".format(k, v)
    curl_cmd += " -O {}".format(url)
    return curl_cmd

key_id = 'KPSXXXXXXXXXX' #Specify the public key ID
url = 'https://d1xxxxxxxxxxxx.cloudfront.net/' #Specify the distribution URL
file = 'Hoya-king-2.png'
expire_date = datetime.utcnow() + timedelta(minutes=1)  #Specify the expiry date

cf_signer = CloudFrontSigner(key_id, rsa_signer)

signed_cookies = generate_signed_cookies(cf_signer, key_id, url + file, expire_date)
r = requests.get(url + file, cookies=signed_cookies)

print(f'Results: {r.status_code}')
print(r.headers)
print(generate_curl_cmd(url + file, signed_cookies)) # returns a curl command for testing

今回はCookieを用いたリクエストですので、コード内のrequests.getで取得が可能かどうか確認します。ステータスコードが200ならばアクセス成功で、ヘッダからも結果が確認できます。

また、確認用に手動で実行する curl コマンドも出力するようにしました。

実行

スクリプトを実行してみると、200が無事に返されました!確認用に出力された curl コマンドを見てみると、 -H オプションで3つのヘッダが付与されていることがわかります。(CloudFront-PolicyとCloudFront-Signatureはだいぶ長い値が入ります)

そして、この curl コマンドをコピーしてすぐに実行してみると、これも200で成功しました。ファイルを保存する-Oオプションを付けているため、画像ファイルも取得することができました。

21-04-21 19:39:46 % python test2.py
Results: 200
æ'Content-Type': 'image/png', 'Content-Length': '436473', 'Connection': 'keep-alive', 'Date': 'Wed, 21 Apr 2021 17:38:39 GMT', 'Last-Modified': 'Wed, 21 Apr 2021 15:35:58 GMT', 'ETag': '"e617e131d9c73e2671b44b4cbb587027"', 'Accept-Ranges': 'bytes', 'Server': 'AmazonS3', 'X-Cache': 'Hit from cloudfront', 'Via': '1.1 f2db75b601dc30df73b1beb29596a375.cloudfront.net (CloudFront)', 'X-Amz-Cf-Pop': 'FRA53-C1', 'X-Amz-Cf-Id': '7fISWUXYMx9gHqFjKKhJQ6vcLZhevETcs3j-uyJn_BuePc2-wb-9tQ==', 'Age': '73'å
curl -D - -H 'Cookie: CloudFront-Policy=eyJxxx' -H 'Cookie: CloudFront-Signature=N6bxxx' -H 'Cookie: CloudFront-Key-Pair-Id=KPSXXXXXXXXXX' -O https://d1xxxxxxxxxxxx.cloudfront.net/Hoya-king-2.png


21-04-21 19:39:51 % curl -D (中略) -O https://d1xxxxxxxxxxxx.cloudfront.net/Hoya-king-2.png
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0HTTP/2 200 
content-type: image/png
content-length: 436473
date: Wed, 21 Apr 2021 17:40:01 GMT
last-modified: Wed, 21 Apr 2021 15:35:58 GMT
etag: "e617e131d9c73e2671b44b4cbb587027"
accept-ranges: bytes
server: AmazonS3
x-cache: Miss from cloudfront
via: 1.1 f2db75b601dc30df73b1beb29596a375.cloudfront.net (CloudFront)
x-amz-cf-pop: FRA53-C1
x-amz-cf-id: 2q5VsSKpUHNjHWKmeMQ5GIs1ue6pSubU680gneUqjP3oToG7Mu9-qw==

100  426k  100  426k    0     0  1068k      0 --:--:-- --:--:-- --:--:-- 1068k

21-04-21 19:41:13 % ll Hoya-king*
-rw-r--r--  1 maiito  staff  436473  4 21 19:41 Hoya-king-2.png

指定した有効期限の1分を過ぎた後、改めて curl コマンドを実行してみました。結果は403エラー、アクセスに失敗します。-Oオプションによりローカルにファイルが生成されていますが、画像ファイル自体にはアクセスできていないため、中身は空(サイズが110B)、表示させることはできません。

21-04-21 19:47:22 % curl -D (中略) -O https://d1xxxxxxxxxxxx.cloudfront.net/Hoya-king-2.png
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0HTTP/2 403 
server: CloudFront
date: Wed, 21 Apr 2021 17:47:25 GMT
content-type: text/xml
content-length: 110
x-cache: Error from cloudfront
via: 1.1 7549433a09d06354ea864d169b689e51.cloudfront.net (CloudFront)
x-amz-cf-pop: FRA53-C1
x-amz-cf-id: gkUNC2uF0JMHsuchjsZHxOBUmNBoeXFYieD5544E8QZLBp1J9J5qMg==

100   110  100   110    0     0    940      0 --:--:-- --:--:-- --:--:--   932

21-04-21 19:48:31 % ll Hoya-king*
-rw-r--r--  1 maiito  staff  110  4 21 19:48 Hoya-king-2.png

以上です。試してみると理解が深まりますね!