【第2回】AmazonS3へダイレクトアクセスするアプリケーション実装(5)


クラウド上のストレージとして、AWSで利用可能なAmazon S3。今回以降はAmazon S3へクライアントからダイレクトアクセスするSpringアプリケーションの実装方法について解説します。

前回は、S3へダイレクトアクセスする方式や、AWSリソースのアクセスを一時的に許可する認証情報を作成するためのサービスであるSTSを説明し、一時的な認証情報を発行するためのIAMロールや、S3へのバケットアクセスポリシーを作成しました。 続く今回は、署名付きURLを使ってS3にあるファイルへダイレクトダウンロードするアプリケーションを実装していきます。


../_images/S3DirectAccessNewIcon.png


署名付きURLを使ってS3にあるファイルへダイレクトダウンロードするアプリケーション


ベースとなるアプリケーションは 基本編第26回 で実装したものと同様のものを使用します。 アプリケーション内からS3へアクセスを行う場合は、Spring Cloud AWSを使ってResourceLoader経由でもデータ取得できましたが、 バケットの操作やダイレクトアップロード時に利用するAWS STSへ一時認証情報の取得処理などは、AmazonSDKのAPIを直接実行する必要があり、今回の実装ではSpring Cloud AWSのライブラリは使用しません。

本連載で実際に作成するアプリケーションでは GitHub 上にコミットしています。 以降に記載するソースコードでは、import文など本質的でない記述を省略している部分があるので、実行コードを作成する際は、必要に応じて適宜GitHubにあるソースコードも参照してください。

今回のアプリケーション実装では、ライブラリとしてSTSやIAMのSDKライブラリが必要になります。基本編で定義した、spring-boot-starter-webなどのライブラリに加えて、以下のSDKライブラリ定義をpom.xmlに追加しましょう。


<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-java-sdk-sts</artifactId>
    <version>1.11.415</version>
  </dependency>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-java-sdk-iam</artifactId>
    <version>1.11.415</version>
  </dependency>
</dependencies>


それでは、アプリケーションの実装の解説に進みます。基本編で作成していたアプリケーションは以下の構成でしたが、今回はダイレクトアクセスを実現するS3DirectDownloadHelperクラスを追加して、SampleControllerに実行するこのHelperを呼び出す処理を適宜追加します。

アプリケーション
コンポーネント 説明 必須
WebApp SpringBootアプリケーションを実行する起動クラス
MvcConfig SpringMVCの設定を行うクラス
S3Config S3への接続に関する設定クラス
SampleController ダウンロード・アップロードを呼び出し画面もしくはJSONレスポンスを返却するController
XxxxxHelper ダウンロード・アップロード各処理を実装したクラス。なおHelperとは TERASOLUNAのガイドライン Helper と同様、Controllerの複雑化を避けるために補助的に作成するクラスです。


以降、解説を進めていきますが、前回説明した通り、事前にAWSコンソールでアプリケーション用のユーザを作成しておき、 AWS公式ページ「設定ファイルと認証情報ファイル」 を参考に ユーザホームフォルダに.awsディレクトリを作成し、credentialというファイル名で、CSV形式の認証キーに記載しているユーザ認証情報を、以下の形式で保存してください。


[default]
aws_access_key_id=XXXXXXXXXXXXXXXX
aws_secret_access_key=YYYYYYYYYYYYYYYYYYYYYYYYYYYYY


それでは、実装していくHelperクラスを説明します。S3へのダイレクトダウンロードアクセスは一時的にアクセスが可能な署名付きURLを発行して、 それをブラウザなどのクライアント側へ渡すことで実現します。Helperクラス内でこの署名付きURLを生成する処理を実装しますが、(a)HTMLのimg要素等からアクセスされる場合と、(b)ブラウザからファイルダウンロードのような形で取得する場合とで少々実装が異なります。 また、(c)JavaScriptからXMLHttpRequestなどを使ってアクセスを行う場合、CORS(Cross Origin Resource Sharing)の設定をS3に行う必要があります。(c)は次回ダイレクトアップロード時に改めて解説しますが、 今回はサーバサイド側で(a)と(b)の場合で分けて、署名付きURLを取得する処理を実装してみましょう。


注釈

CORSについて

ブラウザには、XSS(Cross Site Scripting:クロスサイトスクリプティング)やCSRF(Cross Site Request Forgeries:クロスサイトリクエストフォージェリ)といったセキュリティ脅威に対する対策として、開いているドメイン(http://exampleA.com)とは異なるドメイン(http://exampleB.com)へのアクセスを制御する仕組みがあり、 (同一オリジンポリシー と呼びます)、CORSはクロスドメインでのアクセスを安全に実行するための仕様です。


package org.debugroom.mynavi.sample.aws.s3.app.web.helper;

// omit

import com.amazonaws.HttpMethod;
import com.amazonaws.auth.STSAssumeRoleSessionCredentialsProvider;
import com.amazonaws.auth.policy.Policy;
import com.amazonaws.auth.policy.Statement;
import com.amazonaws.auth.policy.actions.S3Actions;
import com.amazonaws.services.identitymanagement.AmazonIdentityManagementClientBuilder;
import com.amazonaws.services.identitymanagement.model.GetRoleRequest;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.amazonaws.services.s3.model.ResponseHeaderOverrides;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;

// omit

@Component
public class S3DirectDownloadHelper implements InitializingBean {               // …(A)

    private static final String RESOURCE_ARN_PREFIX = "arn:aws:s3:::";
    private static final String DIRECTORY_DELIMITER = "/";

    @Value("${bucket.name}")
    private String bucketName;

    @Value("${sts.min.duration.minutes}")
    private int stsMinDurationMinutes;

    @Value("${s3.download.role.name}")
    private String roleName;                                                    // …(B)
    @Value("${s3.download.role.session.name}")
    private String roleSessionName;                                             // …(C)
    @Value("${s3.download.duration.seconds}")
    private int durationSeconds;                                                // …(D)
    private String roleArn;                                                     // …(E)

    public URL getPresignedUrl(String filePath){                                // …(F)
        AmazonS3 amazonS3 = getS3ClientWithDownloadPolicy(filePath);
        Date expiration = Date.from(ZonedDateTime.now().plusSeconds(durationSeconds).toInstant());
        return amazonS3.generatePresignedUrl(bucketName, filePath, expiration); // …(G)
    }

    public URL getDownloadPresignedUrl(String filePath){                        // …(H)
        AmazonS3 amazonS3 = getS3ClientWithDownloadPolicy(filePath);
        Date expiration = Date.from(ZonedDateTime.now().plusSeconds(durationSeconds).toInstant());
        String fileName = StringUtils.substringAfterLast(filePath, DIRECTORY_DELIMITER);
        ResponseHeaderOverrides responseHeaderOverrides = new ResponseHeaderOverrides();
        responseHeaderOverrides.withContentDisposition(new StringBuilder()
          .append("attachment;filename=")
          .append("".equals(fileName) ? filePath : fileName)
          .toString());                                                         // …(I)
        GeneratePresignedUrlRequest generatePresignedUrlRequest
          = new GeneratePresignedUrlRequest(bucketName, filePath, HttpMethod.GET);
        generatePresignedUrlRequest.withExpiration(expiration);
        generatePresignedUrlRequest.withResponseHeaders(responseHeaderOverrides);
        return amazonS3.generatePresignedUrl(generatePresignedUrlRequest);      // …(J)
    }

    private AmazonS3 getS3ClientWithDownloadPolicy(String objectKey){           // …(K)
        // Create ARN for download S3 Object.
        String resourceArn = new StringBuilder()
          .append(RESOURCE_ARN_PREFIX)
          .append(bucketName)
          .append(DIRECTORY_DELIMITER)
          .append(objectKey)
          .toString();
        // Create IAM Policy provided temporary security credentials.
        Statement statement = new Statement(Statement.Effect.Allow)
          .withActions(S3Actions.GetObject)
          .withResources(new com.amazonaws.auth.policy.Resource(resourceArn));
        String iamPolicy = new Policy().withStatements(statement).toJson();     // …(L)

        // return S3Client with setting above iam policy.
        return AmazonS3ClientBuilder.standard()
          .withCredentials(new STSAssumeRoleSessionCredentialsProvider
                  .Builder(roleArn, roleSessionName)
                  .withRoleSessionDurationSeconds(
                          (int)TimeUnit.MINUTES.toSeconds(stsMinDurationMinutes))
                  .withScopeDownPolicy(iamPolicy)
                  .build())
          .build();                                                             // …(M)
    }

    @Override
    public void afterPropertiesSet() throws Exception {                         // …(N)
        GetRoleRequest getRoleRequest = new GetRoleRequest().withRoleName(roleName);
        roleArn = AmazonIdentityManagementClientBuilder.standard().build()
          .getRole(getRoleRequest).getRole().getArn();                          // …(O)
    }

 }


S3DirectDownloadHelperクラスコードの説明は以下の通りです。


S3DirectDownloadHelperクラスコードの説明
項番 説明
org.springframework.beans.factory.InitializingBeanインターフェースを実装します。これにより、(N)でオーバーライドしたafterPropertiesSet()メソッドがSpringBootの起動時、application.ymlに記載したプロパティが読み込まれた後にコールされることになります。
application.ymlに前回作成したIAMロール名を記載しておき、@Valueでインジェクションします。
application.ymlにRoleSessionNameを記載しておき、@Valueでインジェクションします。RoleSessionNameはIAMロールを利用したユーザとしてCloudTrailなどで表示できます。
application.ymlにダウンロードするリソースへのアクセス継続時間を秒で設定しておき、durationSecondsとして、@Valueでインジェクションします。
(N)で前回作成したIAMロール名のARNを取得し、設定します。
filePathで指定したS3にあるファイルのオブジェクトキーに対し、一時的にアクセス可能な署名付きURLを取得するメソッドです。
HTMLのimg要素などでアクセスされる場合に返却する署名付きURLを生成します。
filePathで指定したS3にあるファイルのオブジェクトキーに対し、ブラウザからのファイルダウンロード用途で、一時的にアクセス可能な署名付きURLを取得するメソッドです。
ファイルダウンロードでは、URLのレスポンスヘッダにおいて、Content-Dispositonヘッダとしてattachment+filenameパラメータを付与します。
(I)で作成したレスポンスヘッダを設定したリクエストを元に、ブラウザのファイルダウンロードでアクセスされる場合に返却する署名付きURLを生成します。
S3にあるファイルオブジェクトキーにダイレクトアクセスするS3クライアントを返すプライベートメソッドです。(F)と(H)のメソッド双方から使用されます。
ダイレクトアクセスするS3にあるファイルオブジェクトキーのARNに対して、IAMポリシーとしてアクセス許可を付与したステートメントのJSON表現を取得します。
STSAssumeRoleSessionCredentialsProviderを使って、(B)〜(E)および(L)で作成したIAMポリシーを設定したS3クライアントを生成して返却します。
このHelperクラスがSpringのDIコンテナによって生成された後に、呼ばれるメソッドです。
AmazonIdentityManagementClientを使用して、ロール名からARNを取得し、(E)に設定します。


当ヘルパークラスで使用するプロパティをapplication.ymlに定義します。


applicaiton.yml

bucket:
  name: debugroom-mynavi-sample
s3:
  download:
    role:
      name: mynavi-sample-s3-download-role
      session:
        name: mynavi-sample
    duration:
      seconds: 600
sts:
  min:
    duration:
      minutes: 15


Controllerクラスで(F)と(H)を呼び出すリクエストハンドラメソッドを実装します。


package org.debugroom.mynavi.sample.aws.s3.app.web;

// omit

@Controller
public class SampleController {

    // omit

    @Autowired
    S3DirectDownloadHelper s3DirectDownloadHelper;

    @GetMapping("portal")
    public String portal(Model model){
       model.addAttribute("imageUrl",
         s3DirectDownloadHelper.getPresignedUrl("sample.jpg").toString());
       return "portal";
    }

    @GetMapping("downloadFile")
    @ResponseBody
    public ResponseEntity<String> downloadFile(){
        return ResponseEntity.ok().body(
         s3DirectDownloadHelper.getDownloadPresignedUrl("test.txt").toString());
    }

    // omit

}


上記のメソッドを呼び出すリクエストは、HTMLやJavaScriptなどのブラウザで実行されるクライアントで行われます。下記は、ThymeleafおよびjQueryを使って、img要素やJavaScriptで、S3へダイレクトダウンロードしている箇所です。


resources/templates/portal.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ja">
<html lang="ja">

// omit

<h2>Get S3 image directly.</h2>
<img th:src="*{imageUrl}" />

<h2>Get S3 Text File directly</h2>
<form id="downloadFileForm" action="/downloadFile" method="get" >
  <button id="downloadFileBodyButton" name="downloadFileBodyButton" class="main-button" type="button" onclick="downloadFile()" >Get TextFile</button>
</form>

// omit
<script type="text/javascript" src="static/js/portal.js"></script>


resources/static/js/portal.js

// omit

function downloadFile() {
    $.get("/downloadFile", function (data) {
        var downloadLink = document.createElement("a");
        downloadLink.href = data.toString();
        downloadLink.click();
    })
}


実際に発行された署名付きURLについては、Chromeのデベロッパーツールなどでダイレクトアクセスしたリクエストで確認できます。


../_images/chrome-developer-tool-s3-direct-download.png


今回は署名付きURLを用いてS3へダイレクトアクセスする実装を紹介しました。次回はS3へダイレクトアップロードする実装を紹介します。


著者紹介

川畑 光平(KAWABATA Kohei) - NTTデータ

../_images/aws_361383_0751.jpeg

金融機関システム業務アプリケーション開発・システム基盤担当、ソフトウェア開発自動化関連の研究開発を経て、デジタル技術関連の研究開発・推進に従事。

Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。

AWS Top Engineers & Ambassadors 選出。

本連載記事の内容に対するご意見・ご質問は Facebook まで。