前回は、S3へダイレクトアクセスする方法や、AWSリソースへのアクセスを一時的に許可する認証情報を作成するためのサービス「STS(Security Token Service)」について説明し、一時的な認証情報を発行するためのIAMロールや、S3へのバケットアクセスポリシーを作成しました。続く今回は、署名付きURLを使ってS3にあるファイルをダイレクトダウンロードするアプリケーションを実装していきます。
解説を始める前に
ベースとなるアプリケーションは連載「AWSで作るクラウドネイティブアプリケーションの基本」の第26回で実装したものを使用します。
アプリケーション内からS3へアクセスを行う場合は、Spring Cloud AWSを使ってResourceLoader経由でもデータ取得できました。しかし、バケットの操作やダイレクトアップロード時に利用するAWS STSへの一時認証情報の取得処理などは、AmazonSDKのAPIを直接実行する必要があるので、今回はSpring Cloud AWSのライブラリは使用しません。
なお、実際のソースコードはGitHub上にコミットしています。以降のソースコードでは本質的でない記述を一部省略しているので、実行コードを作成する場合は、必要に応じて適宜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(TERASOLUNAのガイドラインにあるHelperと同様、Controllerの複雑化を避けるために補助的に作成するクラス)」を呼び出す処理を適宜追加します。
コンポーネント | 説明 | 必須 |
---|---|---|
WebApp | SpringBootアプリケーションを実行する起動クラス | ◯ |
MvcConfig | SpringMVCの設定を行うクラス | ◯ |
S3Config | S3への接続に関する設定クラス | ◯ |
SampleController | ダウンロード/アップロードを呼び出し画面もしくはJSONレスポンスを返却するController | ◯ |
XxxxxHelper | ダウンロード/アップロード各処理を実装したクラス | ◯ |
前回説明した通り、事前に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)ブラウザからファイルダウンロードのようなかたちで取得する場合とで少々実装が異なります。
また、JavaScriptからXMLHttpRequestなどを使ってアクセスを行う場合、S3にCORS(Cross Origin Resource Sharing)※の設定を行う必要がありますが、こちらについては次回ダイレクトアップロードの解説をする際に改めて説明します。
今回はサーバサイド側で(a)と(b)の場合で分けて、署名付きURLを取得する処理を実装してみましょう。S3DirectDownloadHelperクラスのコードは以下のようになります。
※ ブラウザには、XSS(Cross Site Scripting)やCSRF(Cross Site Request Forgeries)といったセキュリティ脅威に対する対策として、開いているドメインとは異なるドメインへのアクセスを制御する仕組みがあり(同一オリジンポリシーと呼びます)、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)
}
}
コードの説明は下表の通りです。
項番 | 説明 |
---|---|
A | org.springframework.beans.factory.InitializingBeanインタフェースを実装します。これにより、NでオーバーライドしたafterPropertiesSet()メソッドがSpringBootの起動時、application.ymlに記載したプロパティが読み込まれた後にコールされることになります |
B | application.ymlに前回作成したIAMロール名を記載しておき、@Valueでインジェクションします |
C | application.ymlにRoleSessionNameを記載しておき、@Valueでインジェクションします。RoleSessionNameはIAMロールを利用したユーザーとしてCloudTrailなどで表示できます |
D | application.ymlにダウンロードするリソースへのアクセス継続時間を秒で設定しておき、durationSecondsとして、@Valueでインジェクションします |
E | Nで前回作成したIAMロール名のARN(AmazonResourceName)を取得し、設定します |
F | filePathで指定したS3にあるファイルのオブジェクトキーに対し、一時的にアクセス可能な署名付きURLを取得するメソッドです |
G | HTMLのimg要素などでアクセスされる場合に返却する署名付きURLを生成します |
H | filePathで指定したS3にあるファイルのオブジェクトキーに対し、ブラウザからのファイルダウンロード用途で、一時的にアクセス可能な署名付きURLを取得するメソッドです |
I | ファイルダウンロードでは、URLのレスポンスヘッダにおいて、Content-Dispositonヘッダとしてattachment+filenameパラメータを付与します |
J | Iで作成したレスポンスヘッダを設定したリクエストを元に、ブラウザのファイルダウンロードでアクセスされる場合に返却する署名付きURLを生成します |
K | S3にあるファイルオブジェクトキーにダイレクトアクセスするS3クライアントを返すプライベートメソッドです。FとHのメソッド双方から使用されます |
L | ダイレクトアクセスするS3にあるファイルオブジェクトキーのARNに対して、IAMポリシーとしてアクセス許可を付与したステートメントのJSON表現を取得します |
M | STSAssumeRoleSessionCredentialsProviderを使って、B~EおよびLで作成したIAMポリシーを設定したS3クライアントを生成して返却します |
N | このHelperクラスがSpringのDIコンテナによって生成された後に、呼ばれるメソッドです |
O | 「AmazonIdentityManagementClient」を使用して、ロール名からARNを取得し、Eに設定します |
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("sample2.jpg").toString());
return "portal";
}
@GetMapping("downloadFile")
@ResponseBody
public ResponseEntity 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のデベロッパーツールなどでダイレクトアクセスしたリクエストで確認できます。
今回は署名付きURLを用いてS3からダイレクトダウンロードする実装を紹介しました。次回は、S3へダイレクトアップロードする実装を紹介します。
著者紹介
川畑 光平(KAWABATA Kohei) - NTTデータ
金融機関システム業務アプリケーション開発・システム基盤担当、ソフトウェア開発自動化関連の研究開発を経て、デジタル技術関連の研究開発・推進に従事。
Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。AWS Top Engineers & Ambassadors選出。
本連載の内容に対するご意見・ご質問は Facebook まで。