AWS Lambda ファイル便(S3とzipでパスワード認証)

この記事は AWS Lambda アドベントカレンダー 20日目の記事です。
前回19日目は、 Keisuke69さんによるAWS LambdaのPricingを読み解く - Qiitaでした。

はじめに

S3でファイル授受をする場合、S3のsigned urlを使うケースが多いかと思います。
しかしながら、たとえば会社の規則でダウンロードURLとパスワードを別のメールで送ることになっているような場合、signed urlが使いにくい場合があります。S3でベーシック認証やDigest認証が欲しいという要望が根強いのはこういう理由も多いようです。
そこで今回は、LambdaとS3を使って、パスワード認証の出来るファイル便を作ってみました。
デモサイト:https://lambda-passwordauth.s3-us-west-2.amazonaws.com/upload.html

仕組み

大まかな仕組みは、以下のようになります。

アップロード部分

ファイルアップロードまで簡単で、AWS SDK for JavaScriptを使って、Cognitoのアノニマス認証をしてからファイルをアップロードします。Cognitoには、S3へのput権限だけついたロールを紐付けしておきます。こちらのエントリ(AWS Lambdaで顔認識)のアップロード部と内容はほぼ同じです。送信するファイルをS3にアップロード後、パスワードを入れたファイルをrequest.jsonという名前のファイルでアップロードします。

Lambdaの処理

Lambdaファンクションでは、クライアントからrequest.jsonがアップロードされた所から処理をはじめます。

var aws = require('aws-sdk');
var fs = require('fs');
var http = require('http');
var CryptoJS = require("crypto-js");
exports.aws = aws;

var s3;
var tmpDir = "/tmp";
var zipFile = tmpDir+"/archive.zip";
var bucket;
var uuid;
var password;
var ctx;
exports.handler = function(event, context) {
  ctx = context;
  bucket = event.Records[0].s3.bucket.name;
  var key = event.Records[0].s3.object.key;
  var region = event.Records[0].awsRegion;
  var op = event.Records[0].eventName;
  if (op !== "ObjectCreated:Put") {
    context.done(null, 'op');
    return;
  }
  var regexp = /upload\/([0-9a-z\-]*)\/request.json/;
  var match = regexp.exec(key);
  if (match === null) {
    context.done(null, 'except upload');
    return;
  }
  uuid = match[1];
  s3 = new aws.S3({
    region : region
  });
  var prefix = "upload/" + uuid + "/";
  loadAllFiles(createZipFile,bucket, prefix);
};
function createZipFile() {
  var request = JSON.parse(fs.readFileSync(tmpDir+"/request.json"));
  password = request.password;
  var child = require('child_process').spawn('java', [ "-cp", "/var/task:/var/task/*", "Zip",
  zipFile,password,tmpDir,"request.json"]);
  child.stdout.on('data', function(data) {
    console.log("stdout:" + data);
  });
  child.stderr.on('data', function(data) {
    console.log("stderr:" + data);
  });
  child.on('close', function(code) {
    uploadZip();
  });
};
function uploadZip(){
  var body = fs.readFileSync(zipFile);
  var passwordDir = CryptoJS.SHA3(password).toString(CryptoJS.enc.Hex);
  var key = "download/"+uuid+"/"+passwordDir+"/archive.zip";
  console.log("upload:"+key);
  s3.putObject({
    Bucket:bucket,
    Body : body,
    Key:key
  }, function(err) {
     if(err){
       console.log(err);
       ctx.done(null,"create:"+key);
     }else{
       ctx.done(null,"error:"+key);
     }
  });
};
function loadAllFiles(callback, bucket, prefix) {
  s3.listObjects({
    Bucket : bucket,
    Prefix : prefix
  }, function(err, data) {
    if (err) {
      throw err;
    } else {
      var cb = (function(jobNum, callback) {
        var counter = 0;
        return function() {
          if (++counter===jobNum) {
            callback();
          }
        };
      })(data.Contents.length, callback);
      for (var i = 0; i < data.Contents.length; i++) {
        console.log(data.Contents[i].Key);
        loadObject(cb, bucket, data.Contents[i].Key);
      }
    }
  });
};
function loadObject(callback, bucket, key) {
  s3.getObject({
    Bucket : bucket,
    Key : key
  }, function(err, data) {
    if (err) {
      throw err;
    } else {
      console.log("load:" + key);
      if(key.endsWith("/")===false){
        var paths = key.split('/');
        var fileName = paths[paths.length-1];
        console.log(tmpDir + '/' + fileName);
        fs.writeFileSync(tmpDir + '/' + fileName, data.Body);
      }
      callback();
    }
  });
};

String.prototype.endsWith = function(suffix) {
  return this.indexOf(suffix, this.length - suffix.length) !== -1;
};

はじめに、request.jsonのパスから、uuidを取得します。次に、アップロードされたファイルをすべて/tmpにコピーします。
コピー後、request.jsonから取得したパスワードを使って、zipファイルを作成します。Lambda環境ではzipコマンドがなさそうだったので、今回はzip4jを使ってパスワード付きzipを作成しました。なお、Lambda functionをzipで固めてアップロードすると、/var/taskに展開されます。ここは読み取りは出来るため、クラススパスを通せばjavaを起動することが出来ます。このため、javaプロセスを起動するところで、-cp /var/task/:/var/task/* となるように設定をしています。
zipを作成後、S3の/download以下にファイルをputします。/downloadの下にuuidでパスを作り、さらにパスワードをSHA3でdigestした値でパスを作ります。以下のようなURLでファイルが配置されることになります。

http://lambda-passwordauth.s3.amazonaws.com/download/f982c3f3-e90e-2dec-8386-34365763xxxx/1e2e9fc2002b002d75198b7503210c05a1baac1234567a3c6d93bcce3a50d7f00fd395bf1647b9cas8d1afcc9c76c289b0c9383ba386a956da4b38934417789e/archive.zip

f982c3f3-e90e-2dec-8386-34365763xxxxの部分がuuid,1e2e9fc2002...934417789eの部分が、パスワードをsha3でdigestしたものです。
クライアント側はこのパスでzipが置かれることが分かっているので、HEADリクエストでポーリングしてzipが出来るのを待ちます。
zip完成後、ダウンロード用のURLが表示されるので、これをファイルを送りたい相手に送ります。設定したパスワードは別メールなどで送ります。

ダウンロード部分

ダウンロード用のhtmlのパスは一律で、今回のデモ環境では https://lambda-passwordauth.s3-us-west-2.amazonaws.com/upload.html?id=${uuid} となります。${uuid}の部分は、アップロード時に作成されたuuidが入ります。
ダウンロード用のhtmlでは、入力されたパスワードを使って、zipファイルのURLを作り、HEADリクエストで存在チェックをします。入力したパスワードが合っていれば、Lambdaで作ったURLと同じURLが出来るため、ファイルがダウンロードできます。また仮にURLが漏れても、zipにもパスワードがかかっているため、パスワードがなければファイルを取り出すことはできません。

おわり

今回作ったデモでは、宅ファイル便的なやつをLambdaとS3で実現できました。これに加えて、LambdaでS3の利用量制限 を使ってS3の利用量制限をかけたり、Lambdaでアクセスカウンタを作った を使ってダウンロード数のカウントをしたりすると、より動的っぽいサイトに出来そうです。

明日21日目はshimy@githubさんによるおもしろエントリの予定です。お楽しみに!