ブラウザからS3に直接アップロード（Security Token Service利用）

ブラウザのJavaScriptから直接S3にアップロードする方法。
サーバサイドに実サーバがあれば、サーバにアップ=>サーバからS3にアップするのが何も考えなくていいので楽だと思うのですが、AWS API Gateway / lambda等を使うシステムだと、そういう中継ができないので、大きなファイルなどをアップロードする場合はブラウザから直接S3にアップロードする形が必須になります。
そんな時に必要な手順を調べました。

軽く調べてみると、

1. JavaScript内にAWSのAccessKey/SecretKeyを埋め込んで、aws-sdk経由でアップロード
2. サーバサイドでS3の編集権限のあるユーザでS3の署名付きURLを取得し、ブラウザサイドに送る
3. サーバサイドでS3の編集権限のある時限ロールをSecurity Token Service（以下STS)で取得、それによりS3の署名付きURLを取得し、ブラウザサイドに送る
4. Amazon Cognitoを使う

があるっぽいです。

それぞれですが、まず1.は自分のAWSアカウントを乗っ取ってくれと言ってるに等しいので問題外。
２.は時限もある署名付きURLを送るので、AccessKey/SecretKeyセットが漏れるわけではないのでいいと言えばいいのですが、署名付きURLにはAccessKeyが含まれるため、セットでは漏れないけど片割れは漏れるということで、その点はよくないかなと。
4.は多分Amazonとしては一番押している方法？だと思うんですけど、すみません私の能力不足で今の所Cognitoが完全に理解できていません...。また、わからないなりに理解した範囲では、取得した時限ロールの一時AccessKey/一時SecretKey/一時SecurityTokenがブラウザサイドに出てきてしまうようなので、一時とはいえそれが出てくるのは良くないんじゃないかなあと感じました。
そこで、3.のサーバサイドでSTSで一時ロールを獲得し、それを使ってS3の署名付きURLを取得して、ブラウザサイドに送る方法を試してみましたので記事を書いてみます。
（本当にこれがベストなのかはよくわかりません...ツッコミお待ちしております）

## S3アップロード用のロールを作成

まず、S3アップロード用のロールを作成します。

ロールを追加し、以下のようにs3へのアクセスポリシーを追加します。

![s3role.png](https://qiita-image-store.s3.amazonaws.com/0/10108/7646adf3-c9ba-1cfa-76d5-3ccaa84c163a.png)

今回、簡単のために管理ポリシーとしてのS3FullAccessを追加しましたが、本来はインラインポリシーでputObjectのみに絞る等した方がよいのは言うまでもありません。
この辺、ポリシーの最適化は私の方でもしっかりできてないので、納得いくポリシーができればまた追記するとして、とりあえず今回は管理ポリシーで話を進めます。

また、信頼関係は下記のような感じで

```text:ロール/信頼関係
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::[ユーザID12桁]:user/[信頼するユーザ名]"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```

のような形で設定します。

また、サーバサイドでSTSを実行するユーザにおいても、このロールに対して、インラインポリシーで

```text:ユーザ/インラインポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "[ID]",
            "Effect": "Allow",
            "Action": [
                "sts:AssumeRole"
            ],
            "Resource": [
                "arn:aws:iam::[ユーザID12桁]:role/[信頼させるロール名]"
            ]
        }
    ]
}
```

という風に設定します。

## サーバサイドのコーディング

サーバサイドではSTSを使って一時ロールを取得し、その一時ロールを利用してS3に署名したURLを発行します。
私はAPI Gateway / lambdaを[Serverlessフレームワーク](http://serverless.com/)経由で使っているので、それでの記法でこの記事では書きます。

```js:APIのhandler.js
'use strict';

var lib    = require('../lib'); //STSの処理とHTTP Response前の処理をまとめた独自ライブラリ
var aws    = require("aws-sdk");
var sts    = new lib.sts("[ロール名]");
var bucket = "[アップロードするバケット名]";

module.exports.handler = function(event, context, cb) {
    //必要な引数は、アップロードするファイルのファイル名とmimetype
    var query = event.queryParameters;    
    if (!query.filename) {
        return lib.respond(cb, 404, 'Parameter "filename" need');
    } else if (!query.mimetype) {
        return lib.respond(cb, 404, 'Parameter "mimetype" need');
    }

    //STSでS3に書き込み権限のあるロールを要求
    sts.assumeRole(function (err, data) {
        if (err) return lib.respond(cb, err);
        else {
            //ロールの取得に成功すれば、それを用いてaws-sdkのs3オブジェクト作成
            var credencials = data.Credentials;
            var s3 = new aws.S3({
                "accessKeyId" : credencials.AccessKeyId,
                "secretAccessKey" : credencials.SecretAccessKey,
                "sessionToken" : credencials.SessionToken
            });
            //s3オブジェクトで、アップロードするURLに対し署名
            var s3_params = {Bucket: bucket, Key: query.filename, ContentType: query.mimetype};
            var signed_url = s3.getSignedUrl('putObject', s3_params);
            //得た署名付きURLをレスポンス
            var response = {"signedurl":signed_url};
            return lib.respond(cb, null, response);
        }
    });
};
```

```js:ライブラリファイル
'use strict';

var aws = require("aws-sdk");

function sts_(roleName) {
    //STSオブジェクト作成
    this.stsobj = new aws.STS({
        "accessKeyId" : "[STS要求するユーザのAccessKey]",
        "secretAccessKey" : "[STS要求するユーザのSecretKey]"
    });
    //権限を要求するロールのARN等設定
    this.param = {
        "RoleArn" : "arn:aws:iam::[ユーザの12桁ID]:role/" + roleName,
        "RoleSessionName" : "session_" + roleName
    };
}
sts_.prototype.assumeRole = function (cb) {
    //ロールの要求実処理
    return this.stsobj.assumeRole(this.param,cb);
};

module.exports = {
    "respond" : function(cb, err, body) {
        if (err) {
            if (typeof err == 'object') {
                if (!err.status) err.status = '400';
                else if (typeof err.status == 'number') err.status = err.status + '';
            } else if ((err + '').match(/^[0-9]{3}$/)) {
                err = {
                    "status" : err,
                    "message" : body
                };
            }
            err = JSON.stringify(err);
        }
        return cb(err, body);
    },
    "sts" : sts_
};
```

## ブラウザサイドのコーディング

ブラウザサイドでは前節で定義したAPIを叩いて署名付きURLを取得し、そこにファイルをPUTすることでS3の上に直接アップロードできます。

```js:ブラウザサイドの処理
    $("#post-submit").on('click', function(){
        if ($("#post-form").valid()) {
            var error     = null;
            var orig_file = $('#post-image').prop('files')[0];
            var up_file   = "[アップロードするs3上でのファイル名]";

            $.ajax({
                url: '//[署名URL取得APIのURL]?filename=' + up_file + '&mimetype=' + orig_file.type,
                type: 'GET'
            }).then(function(data) {
                return $.ajax({
                    url: data.signedurl,
                    type: 'PUT',
                    data: file,
                    contentType: file.type,
                    processData: false
                });
            }).then(function(data){
                console.log("Upload success");
            }).fail(function( data ) {
                error = data.message || data.statusText || data.errorText;
            })
            .always(function( jqXHR, textStatus ) {
                if (error) {
                    console.log("Error: " + error);
                }
            });

            // false を返してデフォルトの動作をキャンセル
            return false;
        }
    });
```

これで、`<input type="file">`で指定されたファイルに対し、API経由でS3の署名URLをSTSを用いて取得し、そこにブラウザサイドからPUTすることで直接アップロードができるようになります。