Amazon S3 で署名付きURLを自動生成するバケットをつくる [AWS CloudFormation テンプレート付き]

こんにちは、広野です。

メールで送れないような巨大なファイルを、一時的に特定の人にだけ安全に送付したいという要望は多いと思います。クラウドのファイル共有サービスの機能を使用している方が多いのではないでしょうか。

もし AWS アカウントをお持ちでしたら、簡単にそんな機能を作ることができます。AWS CloudFormation テンプレートを用意しましたので、要件が合えばそのままお使い頂けます。

やりたいこと

  • 特定の人に、インターネットを介して安全にファイルを送付したい。
  • 送信者がストレージにファイルを配置した後、受信者がファイルをダウンロードする。
  • ダウンロード用 URL は都度生成で、有効期限がある。
  • ダウンロード用 URL を生成する手順はなるべく簡単にしたい。

実装方法

アーキテクチャ

  1. 送信者(ユーザ)は、受信者に送りたいファイルを AWS マネジメントコンソールから所定の Amazon S3 バケットにアップロードします。
  2. Amazon S3 バケットは、ファイルが保存されたことをトリガーにして AWS Lambda 関数にファイルの情報を送信し、AWS Lambda 関数を実行します。
  3. AWS Lambda 関数は、受け取ったファイルの情報から、対象となる Amazon S3 オブジェクトに Amazon S3 署名付き URL を生成します。
  4. AWS Lambda 関数は、生成された Amazon S3 署名付き URL および有効期限をユーザに通知するためのメールを作成し、Amazon SES にメール送信を指示します。
    有効期限は 本環境構築時にパラメータとして設定可能です。最大 36 12 時間、時間単位で設定します。
    ※本ケースでは、トークンが 12 時間しか有効期限がないため、最大が 12 時間となってしまいます。(2022.10.28 追記)
  5. Amazon SES は、受け取ったメール情報からユーザにメールを送信します。ユーザ (送信者) は、ファイル受信者に送られてきた情報を受信者に通知することで、受信者は記載されている URL から有効期限の限りファイルをダウンロード可能です。

本環境は、Amazon SES が有効なリージョンでのみ動きます。現時点では、香港 (ap-east-1) 、ジャカルタ (ap-southeast-3) リージョンでは動きません。その場合は Amazon SES のみ別リージョンをものを使用するように AWS CloudFormation テンプレートをカスタマイズする必要があります。

Amazon SES によるメール送信をさせるために、メールの From と To アドレスを設定する必要があります。これらは AWS CloudFormation のパラメータで設定します。それぞれ、本テンプレートでは1つのみ設定可能としています。

Amazon SES によるメール送信を有効にするためには、以下の条件があります。これらは本記事の AWS CloudFormation テンプレートには含まれていません。

  • To にしたいアドレスが、そのリージョンの Amazon SES に信頼済みメールアドレスとして登録されていること。または、そのリージョンの Amazon SES の Sandbox が解除されていること。Sandbox 解除は AWS サポートへの申請が必要になります。
  • From にしたいアドレスが、そのリージョンの Amazon SES に信頼済みメールアドレスとして登録されていること。

解説

Amazon S3 イベント通知

Amazon S3 バケットへのファイル保存をトリガーに AWS Lambda 関数をトリガーする機能は、Amazon S3 イベント通知を利用しています。イベント通知先として登録できる AWS サービスは限られていますが、AWS Lambda がサポートされているため、事実上どんなサービスでも連携することができます。

Amazon S3 イベント通知 - Amazon Simple Storage Service
バケット上でキーイベントが発生したときに Amazon SNS トピックにメッセージが送信されるように、通知をセットアップして設定します。

AWS Lambda 関数 (Python)

イベント通知を受けた AWS リソースはトリガー元となった Amazon S3 オブジェクトの情報を受け取れるため、そのオブジェクトに対する API 操作を簡単に組み込むことができます。ここでは、AWS Lambda 関数 (Python) の中で AWS リソース操作用の SDK (boto3) をロードし、generate_presigned_url API を実行し Amazon S3 署名付き URL を自動生成しています。

S3 - Boto3 1.34.80 documentation

署名付き URL 生成後は、同じく boto3 に含まれる Amazon SES にメール送信する send_email API を実行しています。

SES - Boto3 1.34.80 documentation

詳細は AWS CloudFormation テンプレート内、AWS Lambda 関数の記述をご覧下さい。

Amazon S3 署名付き URL

Amazon S3 オブジェクトに対して一時的なクレデンシャル付き URL を発行し、その URL を知っている人のみアクセス可能となる URL を Amazon S3 署名付き URL と言います。

署名付き URL を使用したオブジェクトの共有 - Amazon Simple Storage Service
オブジェクトのダウンロード用に署名付き URL を作成して、オブジェクトを別のユーザーと共有できるようオブジェクトを設定する方法について説明します。

有効期限は任意に設定できる、と言いたいところですが、作成するユーザの種類によって上限値が異なります。本記事のアーキテクチャのように AWS Lambda 関数が署名付き URL を生成する場合は、「IAMロール」によって一時的な権限を付与されているため、最大36時間までの有効期限設定が可能となります。IAM ユーザが手動で署名付き URL を生成する場合、またはアクセスキーを使用した自動生成をさせる場合は最大7日間まで有効期限設定が可能です。

署名付き URL の使用 - Amazon Simple Storage Service
AWS セキュリティ認証情報やアクセス許可を必要とせずに、Simple Storage Service (Amazon S3) でオブジェクトを共有またはアップロードするには、署名付き URL を使用します。

通知メールサンプル

Amazon S3 署名付きURLは、以下のようなメールフォーマットで送信されます。メールフォーマットは AWS Lambda 関数の中で整形しています。

AWS CloudFormation テンプレート

Amazon S3 バケットにファイルが残らないよう、Amazon S3 ライフサイクルを設定してファイルが3日間で自動削除される設定を組み込んでいます。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a S3 bucket. The S3 bucket automatically generates S3 presigned URL of an object you put and notify you the information by email. You can not use this template in ap-east-1 and ap-southeast-3 region due to no SES support.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  RecipientEmail:
    Type: String
    Description: Recipient's email address. Only 1 address can be configured and it must be verified in SES in your region if the sandbox is not removed.
    Default: xxx@example.com
    MaxLength: 50
    MinLength: 5
  SenderEmail:
    Type: String
    Description: Sender's email address. Only 1 address can be configured and it must be verified in SES in your region.
    Default: xxxadmin@example.com
    MaxLength: 50
    MinLength: 5
  S3BucketName:
    Type: String
    Description: The S3 bucket name in which you put objects to generate S3 presigned URLs.
    Default: xxx-downloads
    MaxLength: 50
    MinLength: 5
  ExpiresIn:
    Type: Number
    Description: The duration hours that the generated presigned URL is valid for. The maximum value is 12.
    Default: 12
    MaxValue: 12
    MinValue: 1
  CostTagValue:
    Type: String
    Description: The tag value of the resources created by this template. The tag key is fixed name Cost.
    Default: S3PresignedURL
    MaxLength: 50
    MinLength: 1

Resources:
# ------------------------------------------------------------#
# Lambda S3 Invocation Role (IAM)
# ------------------------------------------------------------#
  LambdaS3InvocationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub Lambda-S3InvocationRole-PresignedURL-${S3BucketName}
      Description: This role allows Lambda functions to access S3.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: !Sub Lambda-S3InvocationPolicy-PresignedURL-${S3BucketName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "ses:SendEmail"
                  - "ses:SendRawEmail"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "s3:GetObject"
                  - "s3:PutObject"
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketName}/*"
              - Effect: Allow
                Action: "logs:CreateLogGroup"
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
              - Effect: Allow
                Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*"

# ------------------------------------------------------------#
# S3 Lambda Invocation Permission
# ------------------------------------------------------------#
  S3LambdaInvocationPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt LambdaGenerateS3PresignedURL.Arn
      Action: lambda:InvokeFunction
      Principal: s3.amazonaws.com
      SourceAccount: !Sub ${AWS::AccountId}
      SourceArn: !Sub arn:aws:s3:::${S3BucketName}
    DependsOn: LambdaGenerateS3PresignedURL

# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------#
  S3BucketS3PresignedURL:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref S3BucketName
      LifecycleConfiguration:
        Rules:
          - Id: AutoDelete
            Status: Enabled
            ExpirationInDays: 3
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Function: !GetAtt LambdaGenerateS3PresignedURL.Arn
      Tags:
        - Key: Cost
          Value: !Ref CostTagValue
    DependsOn: S3LambdaInvocationPermission

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaGenerateS3PresignedURL:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub GenerateS3PresignedURL-${S3BucketName}
      Description: !Sub Lambda Function to generate a S3 presigned URL, called from S3 trigger.
      Runtime: python3.9
      Timeout: 30
      MemorySize: 128
      Environment:
        Variables:
          ExpiresIn: !Ref ExpiresIn
      Role: !GetAtt LambdaS3InvocationRole.Arn
      Handler: index.lambda_handler
      Tags:
        - Key: Cost
          Value: !Ref CostTagValue
      Code:
        ZipFile: !Sub |
          import os
          import boto3
          import datetime
          from urllib.parse import unquote_plus
          from botocore.exceptions import ClientError
          s3 = boto3.client('s3')
          ses = boto3.client('ses',region_name='${AWS::Region}')
          def lambda_handler(event, context):
            print(event)
            # Get the S3 bucket name and the key
            bucket = event['Records'][0]['s3']['bucket']['name']
            key = unquote_plus(event['Records'][0]['s3']['object']['key'])
            # Set the duration the presigned URL is valid for
            expiredinsec = int(os.environ['ExpiresIn']) * 3600
            expdelta = datetime.timedelta(seconds=expiredinsec)
            jstdelta = datetime.timedelta(hours=9)
            JST = datetime.timezone(jstdelta, 'JST')
            dtnow = datetime.datetime.now(JST)
            dtexp = dtnow + expdelta
            expdt = dtexp.strftime("%Y/%m/%d %H:%M - %Z")
            try:
              # Generate a Presigned URL
              presigned_url = s3.generate_presigned_url(
                ClientMethod = 'get_object',
                Params = {
                  'Bucket': bucket,
                  'Key': key
                },
                ExpiresIn = expiredinsec,
                HttpMethod = 'GET'
              )
              # Send an email
              res = ses.send_email(
                Destination={
                  'ToAddresses': [
                    '${RecipientEmail}'
                  ]
                },
                Message={
                  'Body': {
                    'Text': {
                      'Charset': 'UTF-8',
                      'Data': 'S3 Presigned URL:\n<' + presigned_url + '>\n\nFile name:\n' + key + '\n\nExpires in:\n' + expdt
                    }
                  },
                  'Subject': {
                    'Charset': 'UTF-8',
                    'Data': 'S3 Presigned URL has been generated: expires in ' + expdt
                  }
                },
                Source='${SenderEmail}'
              )
            except ClientError as e:
              print(e)
            else:
              print('MessageID: ' + res['MessageId'])
    DependsOn:
      - LambdaS3InvocationRole

まとめ

いかがでしたでしょうか?

ファイル送信者から受信者への一方通行の共有ですが、一定のニーズはあるのではないかと思います。AWS CloudFormation テンプレートも参考になると思いますので、要件によってはカスタマイズしてお役立て頂けましたら幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWSは好きですが、それよりもAWSすげー!って気持ちの方が強いです。
取得資格:AWS 認定は12資格、ITサービスマネージャ、ITIL v3 Expert 等
2020 - 2023 Japan AWS Top Engineer 受賞
2022 - 2023 Japan AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする
クラウドに強いによるエンジニアブログです。
SCSKは専門性と豊富な実績を活かしたクラウドサービス USiZE(ユーサイズ)を提供しています。
USiZEサービスサイトでは、お客様のDX推進をワンストップで支援するサービスの詳細や導入事例を紹介しています。
AWSアプリケーション開発クラウドクラウドセキュリティソリューション
シェアする
タイトルとURLをコピーしました