🦁

署名付きURLを使用したS3へのファイルのアップロードとダウンロード

2022/12/23に公開

はじめに

お客様に、動画マニュアルの社内共有サービスを作れないかと相談されたので、会員向けの動画配信サービスの構築方法を検討することになりました。

前回の記事で、MediaConvertを使って、S3にアップロードされた画像をコンバートする処理の技術検討は終わったので、

https://zenn.dev/ashizaki/articles/12d78bbeca0fd3

次は、

  • 限られたユーザー(ログイン済み)のみ動画をアップロードできる
  • 限られたユーザー(ログイン済み)のみ動画を視聴できる

仕組みの検討を行います。
過去に、画像の共有サービスを構築した際は、AmplifyのStorage機能をAmplify SDKから使って、Cognitoで認証されたユーザーのみアップロード、ダウンロード(署名付きURLで)できる方法を構築したことがありますが、今回は認証にAuth0を利用してみたいと考えているため、

  • Auth0でログイン済みのユーザーのみ、動画をアップロードできる
  • Auth0でログイン済みのユーザーのみ、動画を視聴できる
    方法を検討します。

「Auth0 S3 upload」でGoogle検索をかけてみると、Auth0のコミュニティにこのようなスレッドがあり、API Gatewayを使用してアップロードするとよいと記載があります。

ただ、よくよく調べてみると、API GatewayのPayloadの最大サイズは10Mbyteとなっていて、今回のような動画をアップロードすることを想定した場合、10Mbyteだと不足することが想定され、この方法では難しいという結論になりました(間違っていたらすいません)

そこで今回は、アップロード、ダウンロードともに署名付きURLを使って、ファイルのアップロード、ダウンロードの仕組みをAWS CDKで作ってみます。

最終的なコードはこちらから確認できます。

アップロードの仕組みを作成する

仕組みとしては下図のような構成です。

流れとしては、

①:フロントエンドから、AppSyncにGraphQLでMutationを実行
②:AppSyncのリゾルバーから、Lambdaをコールして、S3のアップロード用の署名付きURLを生成し、それをフロントエンドに返す
③:フロントエンドから署名付きURLあてに、ファイルをPUTする

AppSyncの部分は、API Gatewayで実装してもよいと思いますが、最近よく使っているAppSyncを利用することにします。

なお、最終的には、AppSyncの認証を、OIDCで、Auth0で認証するようにする必要がありますが、今回はそこは本質ではないので、API Keyによる認証にしています。

なお、コード全体はGitHubのリポジトリの方を参照してください。また、コードは、アップロードと、ダウンロードのコードが一つのスタックに、混在しているので、説明の順番と、コードの記載の順序が異なりますので、ご了承ください。

CDKの説明

S3バケットの作成

アップロードしたファイルをストアするS3バケットを以下のコードで構築しています。CORSの箇所は、最終的にはもっと精査する必要がありそうです。なお、以下には記載はありませんが、作成したLambdaに、S3バケットへのPutの権限を設定しておく必要があります。

    const resource = new Bucket(this, "Source", {
      serverAccessLogsBucket: logsBucket,
      serverAccessLogsPrefix: "source-bucket-logs/",
      encryption: BucketEncryption.S3_MANAGED,
      publicReadAccess: false,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      cors: [
        {
          allowedHeaders: ["*"],
          allowedMethods: [HttpMethods.GET, HttpMethods.PUT],
          allowedOrigins: ["*"],
          exposedHeaders: [],
          maxAge: 3000,
        },
      ],
    })

S3の署名付きURLを発行するLambda関数

Lambda関数もいつも通りTypeScriptで実装しています。

    const createUploadPresignedUrlLambda = new NodejsFunction(
      this,
      "CreateUploadPresignedUrlLambda",
      {
        entry: path.join(__dirname, "create-put-presigned-url-fn.ts"),
        handler: "handler",
        runtime: Runtime.NODEJS_16_X,
        role: executionLambdaRole,
        environment: {
          REGION: this.region,
          BUCKET: resource.bucketName,
          EXPIRES_IN: "3600",
        },
      },
    )

Lambda関数のコードは、以下のような感じで、AWS SDK v3で実装しています。

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { AppSyncResolverHandler } from "aws-lambda"
import { PresignedUrl } from "lib/api/graphql/API"
import { v4 as uuid } from "uuid"

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,
  }
  const signedUrl = await getSignedUrl(client, new PutObjectCommand(objectParams), { expiresIn })
  console.log(signedUrl)
  return signedUrl
}

export const handler: AppSyncResolverHandler<any, any> = async (event) => {
  const filename = event.arguments.filename
  const { REGION, BUCKET, EXPIRES_IN } = process.env

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

  const guid = uuid()
  const expiresIn = Number(EXPIRES_IN)
  const key = `${guid}/${filename}`

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

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

AppSyncでGraphQL APIを作成する

最後に、AppSyncです。GraphqlApiを作成し、Lambdaのデータソースを作成、関数、リゾルバーの作成を行います。

    const api = new GraphqlApi(this, "GraphqlApi", {
      name: `${this.stackName}-GraphqlApi`,
      schema: SchemaFile.fromAsset("schema.graphql"),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: AuthorizationType.API_KEY,
        },
      },
      logConfig: {
        fieldLogLevel: FieldLogLevel.ALL,
      },
      xrayEnabled: false,
    })

    const createUploadPresignedUrlDataSource = api.addLambdaDataSource(
      "CreateUploadPresignedUrlDataSource",
      createUploadPresignedUrlLambda,
    )

    const createUploadPresignedUrlFunction = new AppsyncFunction(
      this,
      "createUploadPresignedUrlFunction",
      {
        api: api,
        dataSource: createUploadPresignedUrlDataSource,
        name: "CreateUploadPresignedUrlFunction",
        requestMappingTemplate: MappingTemplate.lambdaRequest(),
        responseMappingTemplate: MappingTemplate.lambdaResult(),
      },
    )

    new Resolver(this, "CreateUploadPresignedUrlResolver", {
      api: api,
      typeName: "Mutation",
      fieldName: "createUploadPresignedUrl",
      pipelineConfig: [createUploadPresignedUrlFunction],
      requestMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
      responseMappingTemplate: MappingTemplate.fromString("$util.toJson($ctx.prev.result)"),
    })

なお、GraphQLのスキーマは、以下のように最低限のものです。

type PresignedUrl {
    bucket: String
    key: String
    presignedUrl: AWSURL
}

type Mutation {
    createUploadPresignedUrl(filename: String): PresignedUrl
}

type Query {
    getDownloadPresignedUrl(key: String): PresignedUrl
}

schema {
    mutation: Mutation
    query: Query
}

動作を検証する

AppSyncのコンソールのクエリから、createUploadPresignedUrl Mutationをテストしてみると、ファイル名に応じたアップロード用のURLを発行してくれることが確認できます。

実際に、簡単なフロントエンドを作成し、ファイルをアップロードできることも確認しました。

ダウンロードの仕組みを作成する

次にダウンロードの仕組みを考えます。ダウンロードもS3の署名付きURLを使って実装することは可能そうですが、ダウンロードの場合、CloudFront経由でキャッシュを効かせた方が良いという記事があったので、以下のようにCloudFront経由に変えてみます。

流れとしては、

①:フロントエンドから、AppSyncにGraphQLでQueryを実行
②:AppSyncのリゾルバーから、Lambdaをコールして、CloudFrontのダウンロード用の署名付きURLを生成し、それをフロントエンドに返す
③:フロントエンドから署名付きURLにアクセス

ダウンロードと違うのは、CloudFrontの署名付きURLを使用している部分です。

CDKの説明

公開鍵と、キーグループを作成

CloudFrontの公開鍵と、キーグループを作成します。

    const publicKey = fs.readFileSync(path.join(__dirname, "../keys/public_key.pem"), "utf-8")
    const privateKey = fs.readFileSync(path.join(__dirname, "../keys/private_key.pem"), "utf-8")

    const pubKey = new PublicKey(this, "CloudFrontPubKey", {
      encodedKey: publicKey,
    })

    const keyGroup = new KeyGroup(this, "KeyGroup", {
      items: [pubKey],
    })

公開鍵と秘密鍵は、あらかじめopensslで、以下のコマンドで作成して、keysフォルダ内に配置しておきます(Gitリポジトリには上げていません。

openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem

CloudFrontの作成

CloudFrontを作成し、trustedKeyGroupsに作成したkeyGroupをセットしています。

    const cloudFront = new CloudFrontToS3(this, "CloudFront", {
      existingBucketObj: resource,
      insertHttpSecurityHeaders: false,
      cloudFrontDistributionProps: {
        defaultBehavior: {
          trustedKeyGroups: [keyGroup],
        },
        defaultCacheBehavior: {
          allowedMethods: ["GET", "HEAD", "OPTIONS"],
          Compress: false,
          forwardedValues: {
            queryString: false,
            headers: ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"],
            cookies: { forward: "none" },
          },
          viewerProtocolPolicy: "allow-all",
        },
        loggingConfig: {
          bucket: logsBucket,
          prefix: "cloudfront-logs",
        },
      },
    })

CloudFrontの署名付きURLを作成するLambda関数

まず、Lambdaに、秘密鍵の内容を渡す必要があります。環境変数で、直接渡すのはちょっとどうなんだろうと思い、Secrets Managerを使う方法にしてみます。

正直、この方法だと、CloudFormationのテンプレートに、秘密鍵の内容が残ってしまうので、正直微妙な方法な気がします・・・。秘密鍵は、AWSのコンソールなどで別途Secrets Managerにあらかじめ用意しておくのが正しいように思われます(今回は、技術検証で、CDKに全部まとめておきたいので、とりあえず良しとします)

    const secret = new Secret(this, "GenerateSecretString", {
      secretObjectValue: {
        privateKey: SecretValue.unsafePlainText(privateKey),
      },
    })

次にLambda関数です。こちらもTypeScriptで作成します。LambdaからSecrets Managerにアクセスできるように権限が必要みたいです。

    const createDownloadPresignedUrlLambda = new NodejsFunction(
      this,
      "CreateDownloadPresignedUrlLambda",
      {
        entry: path.join(__dirname, "create-get-presigned-url-fn.ts"),
        handler: "handler",
        runtime: Runtime.NODEJS_16_X,
        role: executionLambdaRole,
        environment: {
          REGION: this.region,
          CLOUDFRONT_DISTRIBUTION_DOMAIN:
            cloudFront.cloudFrontWebDistribution.distributionDomainName,
          PRIVATE_KEY: privateKey,
          PRIVATE_KEY_SECRET_ID: secret.secretName,
          KEY_PAIR_ID: pubKey.publicKeyId,
          EXPIRES_IN: "3600",
        },
      },
    )
    const kmsPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ["kms:Decrypt"],
      resources: ["*"],
    })

    createUploadPresignedUrlLambda.addToRolePolicy(kmsPolicy)
    secret.grantRead(createUploadPresignedUrlLambda)

Lambda関数の方は、以下のようなコードです。

import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"
import { getSignedUrl } from "@aws-sdk/cloudfront-signer"
import { AppSyncResolverHandler } from "aws-lambda"

const client = new SecretsManagerClient({ region: process.env.REGION })

const getPresignedUrl = async (
  cloudfrontDistributionDomain: string,
  s3ObjectKey: string,
  privateKey: string,
  keyPairId: string,
  expiresIn: number,
): Promise<string> => {
  const dateLessThan = new Date()
  dateLessThan.setUTCMinutes(new Date().getUTCMinutes() + expiresIn / 60)
  const signedUrl = await getSignedUrl({
    keyPairId: keyPairId,
    url: `https://${cloudfrontDistributionDomain}/${s3ObjectKey}`,
    dateLessThan: dateLessThan.toISOString(),
    privateKey: privateKey,
  })
  console.log(signedUrl)
  return signedUrl
}

export const handler: AppSyncResolverHandler<any, any> = async (event) => {
  const key = event.arguments.key
  const { REGION, CLOUDFRONT_DISTRIBUTION_DOMAIN, PRIVATE_KEY_SECRET_ID, KEY_PAIR_ID, EXPIRES_IN } =
    process.env

  if (
    !REGION ||
    !CLOUDFRONT_DISTRIBUTION_DOMAIN ||
    !KEY_PAIR_ID ||
    !PRIVATE_KEY_SECRET_ID ||
    !EXPIRES_IN ||
    isNaN(Number(EXPIRES_IN))
  ) {
    throw new Error("invalid environment values")
  }

  const expiresIn = Number(EXPIRES_IN)

  const output = await client.send(
    new GetSecretValueCommand({
      SecretId: PRIVATE_KEY_SECRET_ID,
    }),
  )

  const secret = JSON.parse(output.SecretString!)

  const url = await getPresignedUrl(
    CLOUDFRONT_DISTRIBUTION_DOMAIN,
    key,
    secret.privateKey,
    KEY_PAIR_ID,
    expiresIn,
  )

  return {
    bucket: "",
    key: key,
    presignedUrl: url,
  }
}

AppSyncのMutationの関数とリゾルバを追加

ここは、アップロードとほとんど同じです。MutationがQueryになったくらい。

   const createDownloadPresignedUrlDataSource = api.addLambdaDataSource(
      "CreateDownloadPresignedUrlDataSource",
      createDownloadPresignedUrlLambda,
    )

    const createDownloadPresignedUrlFunction = new AppsyncFunction(
      this,
      "createDownloadPresignedUrlFunction",
      {
        api: api,
        dataSource: createDownloadPresignedUrlDataSource,
        name: "CreateDownloadPresignedUrlFunction",
        requestMappingTemplate: MappingTemplate.lambdaRequest(),
        responseMappingTemplate: MappingTemplate.lambdaResult(),
      },
    )

    new Resolver(this, "CreateDownloadPresignedUrlResolver", {
      api: api,
      typeName: "Query",
      fieldName: "getDownloadPresignedUrl",
      pipelineConfig: [createDownloadPresignedUrlFunction],
      requestMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
      responseMappingTemplate: MappingTemplate.fromString("$util.toJson($ctx.prev.result)"),
    })

動作を検証する

AppSyncコンソールのクエリで、Queryをたたいて、CloudFrontの署名付きURLでファイルを取得できることを確認できました。

最後に

とりあえず、かなり雑ですが、署名付きURLを使ったアップロードと、ダウンロードをCDKで実装してみました。検証用の簡易的なフロントエンドも含めて1日で実装できたので、よかったです。実際には、S3にアップロードしたファイルの情報をDynamoDBに登録したりほかにやらないといけないことがありそうです。

Cognitoを使うのであれば、AmplifyのStorageでもっと楽にできるかもしれませんが、今回はAuth0で認証したいということで、別の方法で実現してみました。

参考サイト

https://adrianhall.github.io/cloud/2018/12/18/handling-file-uploads-with-aws-appsync/
https://sookocheff.com/post/api/uploading-large-payloads-through-api-gateway/
https://qiita.com/sugimount-a/items/382e10fbbc85bbca1f3d

Discussion