署名付きURLを使用したS3へのファイルのアップロードとダウンロード
はじめに
お客様に、動画マニュアルの社内共有サービスを作れないかと相談されたので、会員向けの動画配信サービスの構築方法を検討することになりました。
前回の記事で、MediaConvertを使って、S3にアップロードされた画像をコンバートする処理の技術検討は終わったので、
次は、
- 限られたユーザー(ログイン済み)のみ動画をアップロードできる
- 限られたユーザー(ログイン済み)のみ動画を視聴できる
仕組みの検討を行います。
過去に、画像の共有サービスを構築した際は、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で認証したいということで、別の方法で実現してみました。
参考サイト
Discussion