テックメモ

技術的に気になったことをメモしていきます

S3の署名付きURLを短縮URLでアクセス可能にするプロキシサービスをサーバーレスに構築する

はじめに

S3の署名付きURLをご存知でしょうか?
署名を付けたURLにアクセスすることで、プライベートなS3オブジェクトに対してダウンロードやアップロードを許可することが可能なしくみです。

ただ、この署名部分って結構長いんです。
この署名付きURLをどのように短縮できるのか、というのが今回のテーマです。

アーキテクチャ

[s3 presignedurl shortener]でググってみると、AWSのcompute blogにいい感じの記事が見つかりました。

aws.amazon.com

このアーキテクチャは素晴らしくて、S3のリダイレクト機能を上手く利用してなんとサーバーレスで短縮URLの仕組みを実現しています。
通常、短縮URLといえばリダイレクトで実現することが多いと思いますので、上記のブログも是非見ていただきたいです。

私は都合によりリダイレクトは利用できなかったため、この構成は見送りました。
リダイレクトをせずに署名付きURLを短縮したい。できればサーバーレスで。ということで今回のアーキテクチャはこちらです。

f:id:ginyon:20180110142128p:plain

CloudFrontをS3の前段に配置、Lambda@EdgeでCloudFrontからS3へのリクエストをインターセプトしてリクエストを加工することで、CloudFront - S3間の通信は署名付きURLによる通信としています。ユーザーは短縮URLでCloudFrontにアクセスするだけです。

Lambda@Egdeを利用するアイデアid:kikumotoさんから頂きました!ありがとうございます。

構築手順

CloudFormationのテンプレートを用意しました。
AWSコンソールにログインしていれば、こちらのボタンからStackの作成が可能です。

Create Stack

また、こちらのテンプレートを使うと、DynamoDBへ短縮URLを含んだItemを登録するLambdaが生成されるため、動作確認が簡単に行えます。
(このLambdaは構成図には含まれていません)

一応テンプレートをテキストでも貼っておきます。

AWSTemplateFormatVersion: "2010-09-09"
Description: Serverless Presinged URL Proxy. Must be deploy to us-east-1.

Outputs:
  S3BucketName:
    Description: "Amazon S3 bucket name."
    Value: !Ref S3Bucket
  CloudFrontURL:
    Description: CloudFront Distribution DomainName.
    Value: !Sub "https://${CloudFrontDistrib.DomainName}/"
  DynamoDBTableName:
    Description: DynamoDB TableName.
    Value: !Ref DynamoDBTable

Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    DeletionPolicy: Delete
    DependsOn: LambdaInvokePermission
    Properties:
      BucketName: !Ref "AWS::NoValue"
      NotificationConfiguration:
        LambdaConfigurations:
          -
            Event: "s3:ObjectCreated:*"
            Function: !GetAtt LambdaGenerateS3PresingedURL.Arn 

  DynamoDBTable:
    Type: "AWS::DynamoDB::Table"
    Properties:
      AttributeDefinitions:
        -
          AttributeName: ShortURL
          AttributeType: S
      KeySchema:
        -
          AttributeName: ShortURL
          KeyType: HASH
      ProvisionedThroughput: 
        ReadCapacityUnits: "5"
        WriteCapacityUnits: "5"

  LambdaExecRole:
    Type: "AWS::IAM::Role"
    Properties:
      Policies:
        -
          PolicyName: LambdaExecRolePresignedURLProxy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: Allow
                Action: "logs:*"
                Resource: "arn:aws:logs:*:*:*"
              -
                Effect: Allow
                Action: [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ]
                Resource: !Sub "arn:aws:s3:::*"
              -
                Effect: Allow
                Action: [ "dynamodb:GetItem", "dynamodb:PutItem" ]
                Resource: !GetAtt DynamoDBTable.Arn
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Sid: ""
            Effect: Allow
            Principal:
              Service: [ "lambda.amazonaws.com", "edgelambda.amazonaws.com"]
            Action: "sts:AssumeRole"

  CloudFrontDistrib:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        Origins:
          -
            DomainName: !GetAtt S3Bucket.DomainName
            Id: S3Origin
            S3OriginConfig:
              OriginAccessIdentity: ""
        Comment: CloudFront distribution used as a front end to the server-less Presinged URL Proxy. 
        Enabled: true
        DefaultCacheBehavior:
          AllowedMethods: [ HEAD, DELETE, POST, GET, OPTIONS, PUT, PATCH ]
          CachedMethods: [ HEAD, GET ]
          Compress: true
          DefaultTTL: 60 
          TargetOriginId: "S3Origin"
          ViewerProtocolPolicy: "redirect-to-https"
          ForwardedValues:
            QueryString: 'false'
          LambdaFunctionAssociations:
            -
              EventType: origin-request
              LambdaFunctionARN: !Ref LambdaProxyDeployVersion

  LambdaProxy:
    Type: "AWS::Lambda::Function"
    Properties:
      Handler: index.handler
      MemorySize: 512
      Role: !GetAtt LambdaExecRole.Arn
      Runtime: nodejs6.10
      Timeout: 3
      Code:
        ZipFile:
          !Sub
            - |
              'use strict';

              const aws = require('aws-sdk');
              const docClient = new aws.DynamoDB.DocumentClient({region: '${AWS::Region}'});

              exports.handler = (event, context, callback) => {
                  const request = event.Records[0].cf.request;
                  const httpVersion = request.httpVersion;
                  const uri         = request.uri;
                  var params = {
                      TableName: '${DynamoDBTableName}',
                      Key: {
                          'ShortURL': uri.slice(1)
                      }
                  };
                  docClient.get(params, function(err, data) {
                      const isEmpty = function(obj) {
                          for(var key in obj) {
                              if(obj.hasOwnProperty(key)) {
                                  return false;
                              }
                          }
                          return true;
                      };
                      if (err || isEmpty(data)) {
                          const response = {
                              status: '404',
                              statusDescription: 'Not Found.',
                              httpVersion: httpVersion,
                              body: "404 Not Found.",
                          };
                          callback(null, response);
                          return;
                      }
                      const util = require('util');
                      /* Rewrite uri */
                      request.uri = '/' + data.Item.S3Key;
                      /* Added Query String */
                      request.querystring = data.Item.S3PrisignedQuery;
                      callback(null, request);
                  });
              };
            -
              { DynamoDBTableName: !Ref DynamoDBTable }

  LambdaProxyDeployVersion: 
    Type: "AWS::Lambda::Version"
    Properties: 
      FunctionName: !Ref LambdaProxy

  LambdaGenerateS3PresingedURL:
    Type: "AWS::Lambda::Function"
    Properties:
      Handler: index.handler
      MemorySize: 512
      Role: !GetAtt LambdaExecRole.Arn
      Runtime: nodejs6.10
      Timeout: 30
      Code:
        ZipFile:
          !Sub
            - |
              'use strict';
              
              const aws = require('aws-sdk');
              const endpoint = new aws.Endpoint("s3.amazonaws.com");
              const s3 = new aws.S3({ apiVersion: '2006-03-01', signatureVersion: 'v4', endpoint: endpoint });
              const docClient = new aws.DynamoDB.DocumentClient({region: '${AWS::Region}'});
              const url = require('url');

              exports.handler = (event, context, callback) => {
                  const bucket = event.Records[0].s3.bucket.name;
                  const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
                  const expireSec = 60 * 10;

                  const presingedURL = s3.getSignedUrl('getObject', {
                      Bucket: bucket,
                      Key: key,
                      Expires: expireSec
                  });
                  console.log('Generate PresingedURL.');
                  console.log(presingedURL);
                  const presignedQuery = url.parse(presingedURL).query;
                  const params = {
                      TableName: '${DynamoDBTableName}',
                      Item: {
                          'ShortURL': Math.random().toString(36).slice(-8),
                          'S3Key': key,
                          'S3PrisignedQuery': presignedQuery
                      }
                  };
                  docClient.put(params, function(err, data) {
                      if (err) {
                          console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2));
                      } else {
                          console.log("Added item:", JSON.stringify(data, null, 2));
                      }
                  });
              };
            -
              { DynamoDBTableName: !Ref DynamoDBTable }

  LambdaInvokePermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName: !GetAtt LambdaGenerateS3PresingedURL.Arn 
      Action: "lambda:InvokeFunction"
      Principal: s3.amazonaws.com
      SourceAccount: !Ref AWS::AccountId

動作確認

上記テンプレートを使用した事を前提として動作確認を行っていきます。

Cloud FormationのOutputタブを確認

f:id:ginyon:20180110210534p:plain 出力されている項目を控えておきましょう。

  • CloudFrontURL
  • DynamoDBTableName
  • S3BucketName
S3バケットにオブジェクトをアップロード

S3BucketNameで表示されたバケットにオブジェクトをアップロードします。
今回はhello worldと書かれただけのhtmlをアップロードしました。

$ echo "hello world" > hello.html
$ aws s3 cp hello.html s3://presingedurlproxy2-s3bucket-s98otf4r11ov
upload: ./hello.html to s3://presingedurlproxy2-s3bucket-s98otf4r11ov/hello.html
DynamoDBの登録内容を確認する

DynamoDBTableNameで表示されたテーブルを確認します。
S3 PUTをトリガーに起動されたLambdaにより、新しいItemが登録されていることが確認できます。

$ aws dynamodb scan --table-name PresingedURLProxy2-DynamoDBTable-6PNHTTGS8MNM
{
    "Items": [
        {
            "S3Key": {
                "S": "hello.html"
            },
            "ShortURL": {
                "S": "pee6zuxr"
            },
            "S3PrisignedQuery": {
                "S": "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAJTONTYX635YGUGFQ%2F20180110%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20180110T081138Z&X-Amz-Expires=600&X-Amz-Security-Token=FQoDYXdzEOn%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDIm2aaU%2BCSkXPOo8IyKWAim%2B6fln3qiscBisqXqbF09Ofxn62mNkovBdkxw1riLMxKM%2BumxIwUq19QppQNrmnSyhKBjfvpM5FnLKB441Pc0bmzcuaykjfKUHmiekTtqxKj03gbM%2B8BgwAdaXJbS%2BgeDU%2BB9sAwl2w9j%2BzsqtYSuNF8MtPwmner2eV0jxineURCNIODeklgk%2FyRUKz4U%2BssIbk4%2Bf6qdyNcFBkHV8JO5wsXfduSEjxUPBrqTFt%2FlFFK8QoE01x1b9rl8jZ%2BCfsWMj%2FN6c8boY1QBwQspg4gTd9wC31ROvRc1QCq9B7hU0ie78DPS%2BkGBt%2B5m2Xbt0oAlpgnMr8sPRvKN2wXA2mvY1jYvUeiFoxmMjG0d2ngob7xVrkKCwKImI19IF&X-Amz-Signature=218d4973fc2502ae942f06002ee17f1a582ddf711265da583858ec1ff5b86e67&X-Amz-SignedHeaders=host"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}

ShortURLを控えましょう。

CloudFrontに短縮URLでアクセスする

それでは短縮URLにアクセスしてみます。
ドメインにはCloudFrontURLを使ってください。

$ curl -vvv -X GET https://d14gehme3xkgip.cloudfront.net/pee6zuxr
*   Trying 13.32.50.11...
* Connected to d14gehme3xkgip.cloudfront.net (13.32.50.11) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: *.cloudfront.net
* Server certificate: Symantec Class 3 Secure Server CA - G4
* Server certificate: VeriSign Class 3 Public Primary Certification Authority - G5
> GET /pee6zuxr HTTP/1.1
> Host: d14gehme3xkgip.cloudfront.net
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 12
< Connection: keep-alive
< Date: Wed, 10 Jan 2018 08:21:28 GMT
< Last-Modified: Wed, 10 Jan 2018 08:11:37 GMT
< ETag: "6f5902ac237024bdd0c176cb93063dc4"
< Accept-Ranges: bytes
< Server: AmazonS3
< X-Cache: Miss from cloudfront
< Via: 1.1 9063af643f5f74dbc0e44494f142a87f.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: VYFNZjPSCdntReqTnIGdx6jnD76Tz2lFrMConknNiTnVveRp4xKVRg==
<
hello world
* Connection #0 to host d14gehme3xkgip.cloudfront.net left intact

成功です。

それではS3オブジェクトのパスであるhello.htmlでアクセスするとどうなるか。該当するShortURLがDynamoDBに登録されていないため404になるはずです。

$ curl -vvv -X GET https://d14gehme3xkgip.cloudfront.net/hello.html
*   Trying 54.192.233.250...
* Connected to d14gehme3xkgip.cloudfront.net (54.192.233.250) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: *.cloudfront.net
* Server certificate: Symantec Class 3 Secure Server CA - G4
* Server certificate: VeriSign Class 3 Public Primary Certification Authority - G5
> GET /hello.html HTTP/1.1
> Host: d14gehme3xkgip.cloudfront.net
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Length: 14
< Connection: keep-alive
< Server: CloudFront
< Date: Wed, 10 Jan 2018 08:23:09 GMT
< X-Cache: Error from cloudfront
< Via: 1.1 2fe788985cac89c0ef661ff7cd8edf63.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: tyQof7QnAQy9p5XzryvT0F6jyEFdoBx2dzYoor3H0PmA-bKvSin_AQ==
<
* Connection #0 to host d14gehme3xkgip.cloudfront.net left intact
404 Not Found.

404になりましたね。

なお、今回は試していませんが同じ要領でS3へPUTすることも可能です。
興味がある方はぜひ試してみてください。

解説

今回の構成についての解説です。

署名計算 + DynamoDBへのItem登録処理

S3 Putをトリガーとして起動したLambdaの処理について少しだけ解説です。

まずは、短縮URLに相当する文字列の生成と署名の計算を行います。
そして、DynamoDBへ短縮URL、署名、S3オブジェクトのパスを保存して終了です。

やっていることはこれだけなのですが、署名計算時に一点注意点があります。
署名計算時のホスト名は必ず、バケット名.s3.amazonaws.comとして計算をしてください。
Cloud FrontがS3 Originにアクセスする時にホスト名のリライトをしてくれるのですが、この時に使われるホスト名の形式が上記となるようです。

docs.aws.amazon.com

この形式以外のホスト名を使うと、署名が合わなかったり(署名計算にホスト名を使うため)と色々とハマるポイントが生まれます。
検証時、ap-northeast-1のエンドポイントを使ってしまった為、随分とハマりました。。。

署名付きURLプロキシ

CloudFront + Lambda@Edge + DynamoDB + S3 の構成について。

CloudFront + Lambda@Edge

CloudFrontのオリジンがS3Originとして構成されていると、CloudFrontによりHost名のリライト(cloud frontからs3への変換)が自動的に行われます。
まずはここで第一のリクエストの加工が発生します。

CloudFrontでは、リクエストに該当するキャッシュが無かった場合のみLambda@Edgeがトリガーされます。これはオリジンリクエストで動くLambda@Edgeの仕様です。
トリガーされたLambda@Edgeは、短縮URLをキーにDynamoDBからアイテムを取得します。
該当するアイテムが無ければ404のレスポンスを返しますが、アイテムが取得できた場合はS3へ渡すリクエストを更に加工していきます。

加工するポイントは2つです。
まず、リクエストのURI短縮URLのままなので、S3オブジェクトへのパスへと変換します。
最後に、リクエストのクエリストリングに署名情報を突っ込みます。

これでCloudFrontからS3へのリクエストは、署名付きURLによるリクエストに置き換わりました。
実際に運用される場合は、DynamoDBのTTLや署名の期限なども考慮した方が良いと思います。

さいごに

Lambda@Edgeの上手い使い方がわからなかったのですが、今回はなかなか面白い構成ができたと思っています。
他にもS3にベーシック認証掛けたりもできるようなので、アーキテクチャの選定時にはLambda@Edgeを忘れないでおこうと思います。