LoginSignup
2
4

AWS S3にセキュアな方法でファイルをアップロードする

Last updated at Posted at 2024-05-24

やりたいこと

  • モバイルアプリから、S3にファイルをアップロードしたい
    • 動画など、サイズが大きいファイルもある
  • 外部からはアップロードさせたくないので、S3はパブリックアクセスをブロックする設定にしておく
  • なるべくサーバーレスで構築したい

方式検討

案1: S3に直接アップロード

大抵の言語には、S3アップロードのためのSDKがあると思います。これを使うというのが一番最初に思いつく方法です。

メリット

一番シンプルです。

デメリット

今回の要件ではS3はパブリックアクセスを無効にしているため、ファイルをアップロードするためには認証情報(アクセスキー)が必要です。つまり、モバイルから直接アップロードを実装する場合、認証情報はモバイルのコード内に埋め込まなければなりません。この点がセキュリティ的に致命的です。

また、モバイルのコード内にバケット名やキーも埋め込まなければいけない点、アップロードされるファイルのバリデーションなどが(サーバー側で)できない点もデメリットです。

自分一人しか使わないアプリでもない限り、この方法は採るべきではありません。

案2: API Gateway + Lambda

次に思いつくのは、アップロード用のAPIを用意する方法です。
AWSでAPIを手軽に作るとなれば、まず検討するのはAPI Gateway + Lambdaの構成でしょう。
モバイルからAPIに対して、ファイルを含むリクエストを送り、Lambda内でS3にアップロードを行うという構成になります。

メリット

Lambdaを使うので、認証やバリデーションの処理も一緒に行えるのがメリットです。
モバイルから見るとただのAPIなので、モバイルのコードがAWSインフラに依存しない点もよいです。

デメリット

API Gateway, Lambdaには、それぞれ扱えるリクエストのサイズの上限があります。
API Gatewayは10MB、Lambdaは6MBです。

(↑「ペイロードサイズ」欄)

(↑「呼び出しペイロード」欄)

今回は動画ファイルをアップロードしたいという要件があります。
動画ファイルは、物によりますが1分あたり100MB程度になるため、この方法は採用できません。

案3: 署名済みURLを発行する

S3には、署名済みURLという機能があり、一定時間だけ有効なURLを発行できます。

サーバーでアップロード用の署名済みURLを発行してモバイルに返し、モバイルはそのURLに対してPUTリクエストでファイルをアップロードする、という構成です。

URLを発行をするAPIは、案2のようにAPI Gateway + Lambdaでやってもよいですし、AppSyncのGraphQL APIを使ってもよいです。
今回は、既にAppSyncでAPIを構築していたので、AppSync + Lambdaリゾルバーという形をとることにしました。

メリット

LambdaやAppSyncを前段に挟むので、認証やバリデーションが行えます。
1回目のリクエストではファイル自体は送らないので、認証エラーなどの場合に余計な通信が発生しないのもメリットです。
サイズ制限も問題ありません。ファイル自体はS3に直接送るので、API GatewayやLambdaのサイズ制限を気にする必要はありません。

デメリット

少し複雑な構成になります。
モバイルから見ても、2回サーバーにリクエストを送らなければいけないのが煩雑かもしれません。
AWSインフラ(というかS3)への依存度も、案2と比べると上がります。S3から別のストレージに切り替える場合、モバイル側のコードも変更が必要になるでしょう。

結論:案3を採用

案1と案2には致命的なデメリットがあり採用できないため、消去法的に案3を採用することになります。

なお、既にECSやEC2でAPIを構築してある場合は、シンプルにそのAPIに機能追加でよいと思います。今回はインフラをサーバーレスで作っているため、こういう回りくどい方法になりました。

実装

上述の通り、URLを発行するAPIはAppSync + Lambdaで実装します。

まず、CDK(TypeScript)のコードから。
AppSync APIを作成する部分は割愛。


  /**
   * ファイルアップロードURL生成用のLambda関数を作成する
   * @param api AppSync API
   * @param bucket アップロード先のS3バケット
   */
  createUploadLambda(
    api: appsync.GraphqlApi,
    bucket: Bucket
  ) {
    const lambdaRole = new iam.Role(this, "ExecutionLambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
      ],
    });
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ["s3:PutObject"],
        resources: [
          `${bucket.bucketArn}/*`,
        ],
      })
    );
    const createUploadUrlLambda = new NodejsFunction(
      this,
      "CreateUploadUrlLambda",
      {
        entry: path.join(__dirname, "lambda", "create-upload-url", "index.ts"),
        handler: "handler",
        runtime: lambda.Runtime.NODEJS_20_X,
        role: lambdaRole,
        environment: {
          REGION: cdk.Stack.of(this).region,
          BUCKET: bucket.bucketName,
          EXPIRES_IN: "3600",
        },
      }
    );

    // ここから下はAppSync特有の処理
    // AppSyncのデータソースとしてLambda関数を登録する
    const createUploadUrlDs = api.addLambdaDataSource(
      "CreateUploadUrlDs",
      createUploadUrlLambda
    );

    const createUploadUrlFunction = new appsync.AppsyncFunction(
      this,
      "createUploadUrlFunction",
      {
        api: api,
        dataSource: createUploadUrlDs,
        name: "CreateUploadUrlFunction",
        requestMappingTemplate: MappingTemplate.lambdaRequest(),
        responseMappingTemplate: MappingTemplate.lambdaResult(),
      }
    );

    new appsync.Resolver(this, "CreateUploadUrlResolver", {
      api: api,
      typeName: "Mutation",
      fieldName: "createUploadUrl",
      pipelineConfig: [createUploadUrlFunction],
      requestMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
      responseMappingTemplate: MappingTemplate.fromString(
        "$util.toJson($ctx.prev.result)"
      ),
    });

今回はAppSyncを使用するのでスキーマファイルも必要です。

schema.graphql
# アップロード先のキーを決める情報
input CreateUploadUrlInput {
  file_name: String!
  resource_id: String!
}

# レスポンス
type PresignedUrl {
  bucket: String
  key: String
  presignedUrl: AWSURL
}

type Mutation {
  createUploadUrl(input: CreateUploadUrlInput!): PresignedUrl
}

Lambdaの中身は以下のようになります。

lambda/create-upload-url/index.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AppSyncResolverHandler } from "aws-lambda";
import path from "node:path";

const client = new S3Client({
  region: process.env.REGION,
});

const getPresignedUrl = async (
  bucket: string,
  key: string,
  expiresIn: number
): Promise<string> => {
  const objectParams = {
    Bucket: bucket,
    Key: key,
    StorageClass: "INTELLIGENT_TIERING",
  };
  const signedUrl = await getSignedUrl(
    client,
    new PutObjectCommand(objectParams),
    { expiresIn }
  );
  console.log(signedUrl);
  return signedUrl;
};

export const handler: AppSyncResolverHandler<any, any> = async (event) => {
  const { file_name, resource_id, target } = event.arguments.input;
  const { REGION, BUCKET, EXPIRES_IN } = process.env;

  if (
    !REGION ||
    !BUCKET ||
    !EXPIRES_IN ||
    isNaN(Number(EXPIRES_IN))
  ) {
    throw new Error("invalid environment values");
  }

  const key = buildKey(file_name, resource_id);
  const expiresIn = Number(EXPIRES_IN);

  const url = await getPresignedUrl(BUCKET, key, expiresIn);

  return {
    bucket: BUCKET,
    key: key,
    presignedUrl: url,
  };
};

// keyを組み立てる
const buildKey = (file_name: string, resource_id: string): string => {
  // .mp4
  const extension = path.extname(file_name);
  // hoge
  const stem = path.basename(file_name, extension);
  // YYYYMMDDhhmmsszzz
  const ts = new Date().toISOString().replace(/\D/g, "").slice(0, -3);
  return `${resource_id}/${stem}_${ts}${extension}`;
};

動作確認

GraphQLエンドポイントに対して、以下のリクエストを投げます。

mutation MyMutation {
  createUploadUrl(input: {file_name: "hoge.mp4", resource_id: "1234"}) {
    bucket
    key
    presignedUrl
  }
}

レスポンスが以下。

{
    "data": {
        "createUploadUrl": {
            "bucket": "my-bucket-name",
            "key": "1234/hoge_20240524102405.mp4",
            "presignedUrl": "https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=XXXXXXXX&X-Amz-Date=20240524T102405Z&X-Amz-Expires=3600&X-Amz-Security-Token=XXXXXXXX&X-Amz-Signature=XXXXXXXX&X-Amz-SignedHeaders=host&x-amz-storage-class=INTELLIGENT_TIERING&x-id=PutObject"
        }
    }
}

返ってきたpresignedUrlに対してPUTリクエストを投げることでアップロードできます。

curl -X PUT -H "Content-Type: video/mp4" --data-binary @hoge.mp4 https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?...

参考

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4