こんにちは、広野です。
メールで送れないような巨大なファイルを、一時的に特定の人にだけ安全に送付したいという要望は多いと思います。クラウドのファイル共有サービスの機能を使用している方が多いのではないでしょうか。
もし AWS アカウントをお持ちでしたら、簡単にそんな機能を作ることができます。AWS CloudFormation テンプレートを用意しましたので、要件が合えばそのままお使い頂けます。
やりたいこと
- 特定の人に、インターネットを介して安全にファイルを送付したい。
- 送信者がストレージにファイルを配置した後、受信者がファイルをダウンロードする。
- ダウンロード用 URL は都度生成で、有効期限がある。
- ダウンロード用 URL を生成する手順はなるべく簡単にしたい。
実装方法
アーキテクチャ
- 送信者(ユーザ)は、受信者に送りたいファイルを AWS マネジメントコンソールから所定の Amazon S3 バケットにアップロードします。
- Amazon S3 バケットは、ファイルが保存されたことをトリガーにして AWS Lambda 関数にファイルの情報を送信し、AWS Lambda 関数を実行します。
- AWS Lambda 関数は、受け取ったファイルの情報から、対象となる Amazon S3 オブジェクトに Amazon S3 署名付き URL を生成します。
- AWS Lambda 関数は、生成された Amazon S3 署名付き URL および有効期限をユーザに通知するためのメールを作成し、Amazon SES にメール送信を指示します。
有効期限は 本環境構築時にパラメータとして設定可能です。最大 36 12 時間、時間単位で設定します。
※本ケースでは、トークンが 12 時間しか有効期限がないため、最大が 12 時間となってしまいます。(2022.10.28 追記) - 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 がサポートされているため、事実上どんなサービスでも連携することができます。
AWS Lambda 関数 (Python)
イベント通知を受けた AWS リソースはトリガー元となった Amazon S3 オブジェクトの情報を受け取れるため、そのオブジェクトに対する API 操作を簡単に組み込むことができます。ここでは、AWS Lambda 関数 (Python) の中で AWS リソース操作用の SDK (boto3) をロードし、generate_presigned_url API を実行し Amazon S3 署名付き URL を自動生成しています。
署名付き URL 生成後は、同じく boto3 に含まれる Amazon SES にメール送信する send_email API を実行しています。
詳細は AWS CloudFormation テンプレート内、AWS Lambda 関数の記述をご覧下さい。
Amazon S3 署名付き URL
Amazon S3 オブジェクトに対して一時的なクレデンシャル付き URL を発行し、その URL を知っている人のみアクセス可能となる URL を Amazon S3 署名付き URL と言います。
有効期限は任意に設定できる、と言いたいところですが、作成するユーザの種類によって上限値が異なります。本記事のアーキテクチャのように AWS Lambda 関数が署名付き URL を生成する場合は、「IAMロール」によって一時的な権限を付与されているため、最大36時間までの有効期限設定が可能となります。IAM ユーザが手動で署名付き URL を生成する場合、またはアクセスキーを使用した自動生成をさせる場合は最大7日間まで有効期限設定が可能です。
通知メールサンプル
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 テンプレートも参考になると思いますので、要件によってはカスタマイズしてお役立て頂けましたら幸いです。