AWS S3にREST APIのみで画像アップロードをしてみた

株式会社アップガレージグループ ITソリューション事業部でCrooooberをはじめとしたWebサイトの開発・運用をしています、加藤と申します。

今回は、AWS S3にREST APIのみで画像アップロードをする機能を実現してみたので、記事として公開いたします。

 

 

背景

先日、AWSからこのようなお達しがありました。

2023年6月28日までにすべての AWS リージョンで、すべての AWS APITLS バージョン 1.0 と 1.1 が使用できなくなることを意味します。

aws.amazon.com

弊社にもSDKを用いてS3にファイルアップロードしているSpringアプリがあり、TLSv1.2に対応していない古いバージョンだったためアップグレードを試みたものの、使用しているフレームワークの関係で、期限内でのSDKアップグレードは現実的に困難という判断に至りました。

他の手段を探してみると、S3にはSDKに頼らずRESTのみで完結するAPIが提供されていたため、こちらを使用してアップロード機能を再実装する運びになりました。

PutObject - Amazon Simple Storage Service

 

実装

ファイル内のデータチェックや例外処理は省略しています。

public class S3Util {
/** * S3のRESTでコンテンツをアップロードする */ public static void upload(String s3BucketName, String, s3RegionName, String key, InputStream inputStream) { // ソースコードに秘匿情報を保持するのはセキュリティ的に問題なので、properties等に適宜格納する String awsAccessKey = "アクセスキー"; String awsSecretKey = "セキュリティキー"; URL requestUrl = new URL("https://s3-" + s3RegionName + ".amazonaws.com/" + s3BucketName + "/" + key); // inputStreamをバイナリデータとしてbyte配列に格納する byte[] fileBinaryData = IOUtils.toByteArray(inputStream); // バイナリデータ化されたコンテンツをハッシュ化する byte[] contentHash = AWS4SignerBase.hash(fileBinaryData); String contentHashString = BinaryUtils.toHex(contentHash); Map<String, String> headers = new HashMap<String, String>(); headers.put("x-amz-content-sha256", contentHashString); headers.put("content-length", "" + String.valueOf(fileBinaryData.length)); // S3のストレージクラスも選択可能。今回は頻繁にアクセスするデータのため「STANDARD」を選択 headers.put("x-amz-storage-class", "STANDARD"); // コンテンツのアクセス制限。「public-read」に設定 headers.put("x-amz-acl", "public-read"); // PUTとしてリクエストを準備 AWS4SignerForAuthorizationHeader signer = new AWS4SignerForAuthorizationHeader(requestUrl, "PUT", "s3", s3RegionName); // アクセスキー、シークレットキーを含めて認証情報を生成 String authorization = signer.computeSignature(headers, null, contentHashString, awsAccessKey, awsSecretKey); headers.put("Authorization", authorization); // HttpUtilsにてリクエスト実行 HttpUtils.invokeHttpRequest(requestUrl, "PUT", headers, fileBinaryData); } }

 

public class BinaryUtils {
	/**
	 * バイト配列を16進数でエンコードされた文字列に変換するメソッド
	 */ 
	public static String toHex(byte[] data) {
		StringBuilder sb = new StringBuilder(data.length * 2);
		for (int i = 0; i < data.length; i++) {
			String hex = Integer.toHexString(data[i]);
			if (hex.length() == 1) {
				sb.append("0");
			} else if (hex.length() == 8) {
				hex = hex.substring(6);
			}
			sb.append(hex);
		}
		return sb.toString().toLowerCase(Locale.getDefault());
	}
}

 

public abstract class AWS4SignerBase {
	// 使用する暗号化方式や日時フォーマットなど
	private static final String MAC_ALGORITHM_TYPE = "HmacSHA256";
	private static final String MESSAGE_DIGEST_ALGORITHM_TYPE = "SHA-256";
	private static final String ISO8601BasicFormat = "yyyyMMdd'T'HHmmss'Z'";
	private static final String DateStringFormat = "yyyyMMdd";

	protected URL endpointUrl;
	protected String httpMethod;
	protected String serviceName;
	protected String regionName;

	protected final SimpleDateFormat dateTimeFormat;
	protected final SimpleDateFormat dateStampFormat;

	/**
	 * aws signature v4の共通署名生成用のメソッド
	 */
	public AWS4SignerBase(URL endpointUrl, String httpMethod, String serviceName, String regionName) {
		this.endpointUrl = endpointUrl;
		this.httpMethod = httpMethod;
		this.serviceName = serviceName;
		this.regionName = regionName;

		dateTimeFormat = new SimpleDateFormat(ISO8601BasicFormat);
		dateTimeFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));
		dateStampFormat = new SimpleDateFormat(DateStringFormat);
		dateStampFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));
	}

	protected static String getCanonicalizeHeaderNames(Map<String, String> headers) {
		List<String> sortedHeaders = new ArrayList<String>();
		sortedHeaders.addAll(headers.keySet());
		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);

		StringBuilder buffer = new StringBuilder();
		for (String header : sortedHeaders) {
			if (buffer.length() > 0) buffer.append(";");
			buffer.append(header.toLowerCase());
		}

		return buffer.toString();
	}

	/**
	 * 署名に付与するヘッダー内容の正規化とソート
	 */
	protected static String getCanonicalizedHeaderString(Map<String, String> headers) {
		if (headers == null || headers.isEmpty()) {
			return "";
		}

		List<String> sortedHeaders = new ArrayList<String>();
		sortedHeaders.addAll(headers.keySet());
		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);

		StringBuilder buffer = new StringBuilder();
		for (String key : sortedHeaders) {
			buffer.append(key.toLowerCase().replaceAll("\\s+", " ") + ":" + headers.get(key).replaceAll("\\s+", " "));
			buffer.append("\n");
		}

		return buffer.toString();
	}

	/**
	 * 署名に付与する内容の正規化
	 */
	protected static String getCanonicalRequest(URL endpoint, String httpMethod, String queryParameters, String canonicalizedHeaderNames, String canonicalizedHeaders, String bodyHash) {
		String canonicalRequest =
				httpMethod + "\n" +
				getCanonicalizedResourcePath(endpoint) + "\n" +
				queryParameters + "\n" +
				canonicalizedHeaders + "\n" +
				canonicalizedHeaderNames + "\n" +
				bodyHash;
		return canonicalRequest;
	}

	protected static String getCanonicalizedResourcePath(URL endpoint) {
		if (endpoint == null) {
			return "/";
		}
		String path = endpoint.getPath();
		if (path == null || path.isEmpty()) {
			return "/";
		}

		String encodedPath = HttpUtils.urlEncode(path, true);
		if (encodedPath.startsWith("/")) {
			return encodedPath;
		} else {
			return "/".concat(encodedPath);
		}
	}

	/**
	 * パラメータマップの正規化
	 */
	public static String getCanonicalizedQueryString(Map<String, String> parameters) {
		if (parameters == null || parameters.isEmpty()) {
			return "";
		}

		SortedMap<String, String> sorted = new TreeMap<String, String>();

		Iterator<Map.Entry<String, String>> pairs = parameters.entrySet().iterator();
		while (pairs.hasNext()) {
			Map.Entry<String, String> pair = pairs.next();
			String key = pair.getKey();
			String value = pair.getValue();
			sorted.put(HttpUtils.urlEncode(key, false), HttpUtils.urlEncode(value, false));
		}

		StringBuilder builder = new StringBuilder();
		pairs = sorted.entrySet().iterator();
		while (pairs.hasNext()) {
			Map.Entry<String, String> pair = pairs.next();
			builder.append(pair.getKey());
			builder.append("=");
			builder.append(pair.getValue());
			if (pairs.hasNext()) {
				builder.append("&");
			}
		}

		return builder.toString();
	}

	/**
	 * 署名の生成
	 */	
	protected static String getStringToSign(String scheme, String algorithm, String dateTime, String scope, String canonicalRequest) {
		String stringToSign =
				scheme + "-" + algorithm + "\n" +
				dateTime + "\n" +
				scope + "\n" +
				BinaryUtils.toHex(hash(canonicalRequest));
		return stringToSign;
	}

	/**
	 * Stringデータを元にハッシュ化
	 */
	public static byte[] hash(String text) {
		MessageDigest digest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM_TYPE);
		return digest.digest(text.getBytes(StandardCharsets.UTF_8));
	}

	/**
	 * Byteデータを元にハッシュ化
	 */
	public static byte[] hash(byte[] data) {
		MessageDigest md = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM_TYPE);
		md.update(data);
		return md.digest();
	}

	/**
	 * 指定された文字列をHMACアルゴリズムで暗号化
	 */
	protected static byte[] sign(String stringData, byte[] key) {
		byte[] data = stringData.getBytes("UTF-8");
		Mac mac = Mac.getInstance(MAC_ALGORITHM_TYPE);
		mac.init(new SecretKeySpec(key, MAC_ALGORITHM_TYPE));
		return mac.doFinal(data);
	}
}

 

public class AWS4SignerForAuthorizationHeader extends AWS4SignerBase {

	private static final String ALGORITHM = "HMAC-SHA256";
	private static final String SCHEME = "AWS4";
	private static final String TERMINATOR = "aws4_request";

	public AWS4SignerForAuthorizationHeader(URL endpointUrl, String httpMethod, String serviceName, String regionName) {
		super(endpointUrl, httpMethod, serviceName, regionName);
	}

	/**
	 * 署名生成処理の呼び出し元
	 */
	public String computeSignature(Map<String, String> headers, Map<String, String> queryParameters, String bodyHash, String awsAccessKey, String awsSecretKey) {
		Date now = new Date();
		String dateTimeStamp = dateTimeFormat.format(now);

		headers.put("x-amz-date", dateTimeStamp);
	
		String hostHeader = endpointUrl.getHost();
		int port = endpointUrl.getPort();
		if (port > -1) {
		    hostHeader.concat(":" + Integer.toString(port));
		}
		headers.put("Host", hostHeader);

		String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers);
		String canonicalizedHeaders = getCanonicalizedHeaderString(headers);
		String canonicalizedQueryParameters = getCanonicalizedQueryString(queryParameters);
		String canonicalRequest = getCanonicalRequest(endpointUrl, httpMethod, canonicalizedQueryParameters, canonicalizedHeaderNames, canonicalizedHeaders, bodyHash);
	
		String dateStamp = dateStampFormat.format(now);
		String scope =  dateStamp + "/" + regionName + "/" + serviceName + "/" + TERMINATOR;
		String stringToSign = getStringToSign(SCHEME, ALGORITHM, dateTimeStamp, scope, canonicalRequest);

		byte[] kSecret = (SCHEME + awsSecretKey).getBytes();
		byte[] kDate = sign(dateStamp, kSecret);
		byte[] kRegion = sign(regionName, kDate);
		byte[] kService = sign(serviceName, kRegion);
		byte[] kSigning = sign(TERMINATOR, kService);
		byte[] signature = sign(stringToSign, kSigning);
	
		String credentialsAuthorizationHeader = "Credential=" + awsAccessKey + "/" + scope;
		String signedHeadersAuthorizationHeader = "SignedHeaders=" + canonicalizedHeaderNames;
		String signatureAuthorizationHeader = "Signature=" + BinaryUtils.toHex(signature);

		String authorizationHeader = SCHEME + "-" + ALGORITHM + " "
				+ credentialsAuthorizationHeader + ", "
				+ signedHeadersAuthorizationHeader + ", "
				+ signatureAuthorizationHeader;

		return authorizationHeader;
	}
}
参考

docs.aws.amazon.com

docs.aws.amazon.com

 

わかったこと

今回は苦肉の策で自前で実装しましたが、認証周りは複雑なためAWSからもSDKの使用が推奨されております。

フレームワークやライブラリはこまめにバージョンアップを行なうよう、仕組みづくりをしていきます。

 

おまけ

S3のプロトコル制限

開発期間中はまだAWS側でTLSv1.2の制限が掛かっていなかったため、バケットに以下のポリシーを設定して意図的に制限を掛けておりました。

s3:TlsVersionの箇所を変更すれば、任意のプロトコルに制限を掛けることが可能です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
"Action": "s3:GetObject", "Principal": "*", "Resource": "arn:aws:s3:::{S3のバケット名}/*", "Condition": { "StringNotEquals": { "s3:TlsVersion": "1.2" } } } ] }
参考

docs.aws.amazon.com

 

ポリシーが適用されてされているかの確認

SSLContextなどで任意の通信プロトコルを指定する方法も検討しましたが、サクっと確認したかったのでFireFoxの設定を変更して確認することにしました。

なお、通信プロトコル変更した状態での通信はデータ漏洩に繋がる危険がございますので、使用の際は自己責任でお願いいたします。

 

事前設定

1. アドレスバーに「about:config」と入力、Enter

2. 危険を承知の上で使用しましょう

3. アドレスバーに「security.tls.version」と入力

4. 「security.tls.version.max」と「security.tls.version.min」を任意の値にする

本来はブラウザ側が最適なプロトコルを選択して接続してくれますが、これを固定化して検証ツールとして用いることも可能です。なお、各値とプロトコルの紐付けは以下となっております。

数値 プロトコル
1 TLS 1.0
2 TLS 1.1
3 TLS 1.2
4 TLS 1.3

 

検証

非対応プロトコルでの接続が制限されることを確認

 

対応プロトコルでの接続に成功することを確認

 

作業終了後は元の値(2023年現在で「max: 4」「min: 3」)に戻しておきましょう。