KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

Presigned URLを利用したS3へのファイルアップロード

はじめに

こんにちは、LINE上で動くおくすり連絡帳 Pocket Musubi というサービスを開発している種岡です。 社内システムからS3にファイルをアップロードする機能を開発することになり、Presigned URLを利用して開発を試みたものの、想定以上に時間がかかってしまいました。 S3の設定からバックエンド、フロントエンドと一気通貫での情報がまとまっていないことが課題として浮かんできたので、備忘録として残しておくことにしました。

ゴール

  1. クライアント側(Angular)のフォームで選択したファイルをS3にアップロードできること
  2. S3にアップロードする際は Presigned URL を利用すること
  3. アップロードされたオブジェクトは 読み込みのみのパブリックアクセス のアクセス許可が付与されていること

Presigned URLとは

AWSのドキュメントの署名付きURLの使用を抜粋すると

デフォルトでは、すべてのオブジェクトおよびバケットはプライベートです。 ただし、署名付き URL を使用して、オプションでオブジェクトを共有したり、顧客/ユーザーが AWS セキュリティ認証情報またはアクセス許可なしでオブジェクトをバケットにアップロードしたりできます。

とあるように、S3はデフォルトでバケットもオブジェクトもプライベートです。 オブジェクトの参照やアップロードを許可するためには、利用ユーザーやオブジェクト単位で細かなアクセス制御が必要になり煩雑です。 基本はプライベートなままで、一時的にS3への操作権限を付与し、ポリシーを緩めることなく参照やアップロードを可能にするのがPresigned URLの特長です。 以下はサンプルのPresigned URLですが、クエリパラメーターを見ると署名情報が付与されているのが確認できます。 X-Amz-Expires の部分からわかるようにURLには有効期限が付与されていて、仮に漏洩した場合でも被害を最小限に留めることができます。

https://s3.amazonaws.com/dummy_bucket/files/sample.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=dummy%2F20211228%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20211228T110614Z&X-Amz-Expires=60&X-Amz-SignedHeaders=content-type%3Bhost%3Bx-amz-acl&X-Amz-Signature=1e6473e4f3b66c8ba9f21c0957b0bd2d9cb634535d96598ea1bf8b91f1b1d522

システム全体像

それぞれの設定について説明していく前にシステムの大まかな全体像はこのようになっています。

s3-presigned-url.drawio.png (31.4 kB)

S3の設定

まずはS3のCORSの設定についてです。 クライアントアプリからPresigned URLでS3にアップロードするため、クライアントアプリのドメインをS3側で許可する必要があります。 こちらを参考にS3のバケットに対してCORSの設定を行います。

スクリーンショット 2021-12-28 13.04.35.png (53.2 kB)

バックエンド

後述するクライアント側からのリクエストに応じてPresigned URLを発行できるようにします。 Presigned URLが発行できるようにLambdaにIAM Policyを付与しておきます。

  • GetObject(ダウンロード、表示)
  • PutObject(アップロード)
  • PutObjectAcl (オブジェクト毎の権限)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "*"
    },
  ]
}
import boto3
from botocore.client import Config

def generate_presigned_url(key: str, content_type: str) -> str:
    s3_client = boto3.client("s3", config=Config(signature_version="s3v4")) # ①
    expires_in = 60 * 2
    bucket_name = "sample-bucket"

    return s3_client.generate_presigned_url(
        ClientMethod="put_object",
        ExpiresIn=expires_in,
        Params={
            "Bucket": bucket_name,
            "Key": key,
            "ACL": "public-read", # ②
            "ContentType": content_type # ③
        },
        HttpMethod="PUT",
    )

key = "uploads/サンプル.pdf" # S3の保存先のパス
content_type = "application/pdf"
generate_presigned_url(key, content_type)

以下の点を考慮しないと SignatureDoesNotMatch が発生するため注意が必要です。

  1. 署名バージョン4の指定(①の部分)
  2. パブリックアクセス(読み取り)の指定(②の部分)
  3. ContentTypeの指定(③の部分)

generate_presigned_urlの引数であるParamsの部分に関してはこちらにあるように詳しく記載されていないため情報収集しながら手探りで実装していきました。

クライアント

バックエンド側で発行されたPresigned URLを取得後、Presigned URLを使ってS3にファイルをアップロードします。

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export class PresignedUrlService {
  constructor(private http: HttpClient) {}

  putToS3$(presignedUrl: string, file: FormData): Observable<any> {
    return this.http.put(presignedUrl, file, { headers: { "x-amz-acl": "public-read" } });
  }
}

バックエンド側でACLの設定を含んだPresigned URLを発行しているため、クライアント側ではHeaderに x-amz-acl を追加しています。 指定しないと SignatureDoesNotMatch が発生します。

おわりに

バックエンドのLambdaがPresigned URLを発行し、クライアントアプリがPresigned URLを使って目的のファイルをS3にアップロードすることができました。 開発時は度々 SignatureDoesNotMatch に悩まされ、原因特定に奔走していました。 断片的な情報を集約し、体系的にまとめたことで同様の事象に遭遇した方のお役に立てばと思います。