11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

JavaでAWS Signature V4を生成してAPIをリクエストする

Last updated at Posted at 2019-05-06

はじめに

AWS APIは、リクエストにAWS Signature V4という署名をつけることでIAM認証を利用できます。普通はAWS SDKを利用することでSignature V4の仕様をさほど意識する必要はないのですが、諸事情で、自前によるSignature V4実装をする機会がありましたので、メモを残します。

Java 11で実装しており、HTTPクライアントはApache HttpComponents Clientを使ってます。

コードは切り貼り、加工しているので、正しく動作するか若干怪しいです。

署名しない時のリクエスト

 わかりやすくするため、署名しないときのリクエストサンプルを示します。 Elasticsearch APIで検索リクエストするサンプルです。このサンプルではPOSTメソッドを利用しているためクエリパラメータはHTTP bodyに書いています。

このリクエストにAWS Signature V4署名を施し、認証されたユーザからのみAPIを受けるように改修していきます。

import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClientBuilder;

void reqFunc(){
    // リクエストの生成
    URI uri                 = new URI(AWS_ELASTICSEARCH_URL + "/index/hoge/_search?");
	HttpPost post           = new HttpPost(uri);
	post.setHeader("Content-Type", "application/json");
	post.setEntity(new StringEntity( "クエリー文字列(省略)", "UTF-8"));

    // HTTPクライアントでリクエストの実行
    HttpClient client       = HttpClientBuilder.create().build();
    CloseableHttpResponse res = (CloseableHttpResponse) client.execute(post);

    // ...レスポンスに対する処理...
}

署名に関する情報

AWS Signature V4はAmazonのWebサイトで情報提供がされています。下記のURLからたどって情報を得ました。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html

また、認証に必要となるアクセスキーIDとシークレットアクセスキーの取得方法は、下記のURLからたどって情報を得ています。
https://docs.aws.amazon.com/ja_jp/general/latest/gr/aws-sec-cred-types.html

署名の実装

AWS Signature V4は、リクエスト情報そのものと、予め払い出されているアクセスキーID/シークレットアクセスキーを使いハッシュを生成し、リクエストヘッダに付与します。何度も繰り返しハッシュ化したり、ハッシュの対象対象がどこまでなのか分からず、試行錯誤しました。

HttpRequestInterceptorクラスの作成

最初に、送信するリクエスト自身の情報を取得するため、HttpRequestInterceptorクラスと、それを挟み込んだHTTPクライアントを用意します。HTTPクライアントを生成する際にこのクラスを挟むことで、サーバへのリクエスト送信直前に処理を差し込むことができます。

  • HttpRequestInterceptor
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.protocol.HttpContext;

public class AmazonizeInterceptor implements HttpRequestInterceptor {

	@Override
	public void process(HttpRequest request, HttpContext context) throws Exception{
        AwsSigner4(request);  
	}

    private void AwsSigner4(HttpRequest request) throws Exception {
        /* この関数実装内でAWS Signature V4の実装をします。 */
    }
}
  • HTTPクライアント呼び出し部の修正
//...略...

void reqFunc(){
    //...略...

    // HTTPクライアントでリクエストの実行
    HttpClient client = HttpClientBuilder.create()
                        .addInterceptorLast(new AmazonizeInterceptor())  // ←これを追加する
                        .build();
    CloseableHttpResponse res = (CloseableHttpResponse) client.execute(post);

    //...レスポンスに対する処理...
}

これで、RequestInterceptorを挟み込んだHTTPクライアントでリクエストを行うと、サーバ送信前に先程定義したAwsSigner4関数が実行されるようになります。

AwsSigner4関数の実装

次に実際の署名処理を行うAwsSigner4関数の実装をしていきます。
処理内容は、リクエストヘッダーに「X-Amz-Date」と「Authorization」を追加するだけですが、Authorizationの値を求めるのに何段階か必要になります。

  1. 正規リクエスト文字列(canonicalRequest)の生成
  2. 署名文字列(StringToSign)の生成
  3. 署名キー(SigningKey)の生成
  4. Authorizationヘッダー文字列の生成

参考までに、canonicalRequestに含まれるヘッダー情報は、host,x-amz-dateが必須ですが、それ以外のヘッダー情報を追加しても良いみたいです。

import org.apache.http.util.EntityUtils;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpRequest;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.text.SimpleDateFormat;

private void AwsSigner4(HttpRequest request) throws Exception {

    /* X-Amz-Dateヘッダーの生成 */
    SimpleDateFormat xAmzDateFormatter	= new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
    xAmzDateFormatter.setTimeZone(Calendar.getInstance(TimeZone.getTimeZone("UTC"))); // Signerの有効期限はUTCで判定される
    String xAmzDate 		= xAmzDateFormatter.format(new Date()).trim();
    request.setHeader("X-Amz-Date", xAmzDate);

    /* Authorizationヘッダー文字列の生成 */
    /* 1. 正規リクエスト文字列(canonicalRequest)の生成 */
    String path 			= getPath(request);
    String xAmzContentSha   = getBodyHash(request);
    String canonicalRequest = "POST\n"
                        + path + "\n"
                        + "\n"
                        + "host:" + request.getFirstHeader("Host").getValue() + "\n"
                        + "x-amz-date:" + xAmzDate + "\n"
                        + "\n"
                        + "host;x-amz-date\n"
                        + xAmzContentSha;

    /* 2. 署名文字列(StringToSign)の生成 */
    String awsRegion = "ap-northeast-1" ;  // AWS APIリクエスト先のリージョン情報
    String awsNameSpace = "es" ;  // リクエストするAWSサービスの名前空間
    String StringToSign = "AWS4-HMAC-SHA256\n"
                        + xAmzDate + "\n"
                        + xAmzDate.substring(0, 8) + "/" + awsRegion 
                        + "/" + awsNameSpace +"/aws4_request\n"
                        + DigestUtils.sha256Hex(canonicalRequest);

    /* 3. 署名キー(SigningKey)の生成 */
    String awsSecretAccessKey = "AWSシークレットアクセスキー" ;
    // X-Amz-Date → リージョン → AWSサービス名前空間 → 固定文字(aws4_request) の順でハッシュ化していく
    String hashStr    = getHmacSha256ByStrKey("AWS4" + awsSecretAccessKey, xAmzDate.substring(0, 8));
    hashStr           = getHmacSha256ByHexKey(hashStr, awsRegion);
    hashStr           = getHmacSha256ByHexKey(hashStr, awsNameSpace);
    String SigningKey = getHmacSha256ByHexKey(hashStr, "aws4_request");

    /* 4. Authorizationヘッダー文字列の生成 */
    String awsAccessKeyId = "AWSアクセスキーID" ;
    String sig 			= getHmacSha256ByHexKey(SigningKey, StringToSign);
    String authorization = "AWS4-HMAC-SHA256 Credential="
                        + awsAccessKeyId
                        + "/" + xAmzDate.substring(0, 8)
                        + "/" + awsRegion
                        + "/" + awsNameSpace
                        + "/aws4_request,"
                        + "SignedHeaders=host;x-amz-date,"
                        + "Signature=" + sig;

    request.setHeader("Authorization", authorization);
}

/***  リクエストパスの取得 */
private String getPath(HttpRequest req) throws Exception {
    String uri				= req.getRequestLine().getUri();

    // URLのクエリ文字列とのセパレータである「?」はpathに含めない
    if (uri.endsWith("?"))  uri = uri.substring(0, uri.length()-1);
    
    // URLエンコードも色々種類があるらしく、Amazonが指定したロジックでエンコードする
    return awsUriEncode(uri,true);
}

/*** リクスとボディのハッシュ取得 */
private String getBodyHash(HttpRequest req) throws Exception{
    HttpEntityEnclosingRequest ereq = (HttpEntityEnclosingRequest) req;
    String body				= EntityUtils.toString(ereq.getEntity());
    return DigestUtils.sha256Hex(body);
}

/***
    * AWS指定スペックのURLエンコーダ
    * @param input
    * @param encodeSlash
    * @return
    * @throws UnsupportedEncodingException
    */
private String awsUriEncode(CharSequence input, boolean encodeSlash) throws UnsupportedEncodingException {
    StringBuilder result = new StringBuilder();
    boolean queryIn = false;
    for (int i = 0; i < input.length(); i++) {
        char ch = input.charAt(i);
        if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '~' || ch == '.') {
            result.append(ch);
        } else if (ch == '/') {
            if (queryIn) result.append(encodeSlash ? "%2F" : ch);
            else result.append(ch);
        } else {
            if(!queryIn && ch=='?') {
                queryIn = true;
                result.append(ch);
            }else {
                byte[] bytes = new String(new char[] {ch}).getBytes("UTF-8");
                result.append("%" + Hex.encodeHexString(bytes,false));
            }
        }
    }
    return result.toString();
}

private String getHmacSha256ByStrKey(String strkey, String target) throws Exception {
    return getHmacSha256(strkey.getBytes(), target);
}

private String getHmacSha256ByHexKey(String hexkey, String target) throws Exception {
    return getHmacSha256(Hex.decodeHex(hexkey), target);
}

/***
    * target文字列をKeyを使いHMAC-SHA-256にハッシュ化する
    * @param target ハッシュ対象
    * @param key キー
    * @return Hex形式のハッシュ値
    */
private String getHmacSha256(byte[] key, String target) throws Exception {
    final Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    return String.valueOf(Hex.encodeHex(mac.doFinal(target.getBytes()), true));
}

まとめ

最終的にまとめると下記のようになりました。
リクエスト毎にフォーマッターを生成したり、定数が埋め込みになっていたりでアレですが、そこら辺はよしなに直してください。

import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.codec.binary.Hex;
import java.text.SimpleDateFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class AmazonizeInterceptor implements HttpRequestInterceptor {

	@Override
	public void process(HttpRequest request, HttpContext context) throws Exception{
        AwsSigner4(request);  
	}

    private void AwsSigner4(HttpRequest request) throws Exception {
        /* X-Amz-Dateヘッダーの生成 */
        SimpleDateFormat xAmzDateFormatter	= new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
        xAmzDateFormatter.setTimeZone(Calendar.getInstance(TimeZone.getTimeZone("UTC"))); // Signerの有効期限はUTCで判定される
        String xAmzDate 		= xAmzDateFormatter.format(new Date()).trim();
        request.setHeader("X-Amz-Date", xAmzDate);

        /* Authorizationヘッダー文字列の生成 */
        /* 1. 正規リクエスト文字列(canonicalRequest)の生成 */
        String path 			= getPath(request);
        String xAmzContentSha   = getBodyHash(request);
        String canonicalRequest = "POST\n"
                            + path + "\n"
                            + "\n"
                            + "host:" + request.getFirstHeader("Host").getValue() + "\n"
                            + "x-amz-date:" + xAmzDate + "\n"
                            + "\n"
                            + "host;x-amz-date\n"
                            + xAmzContentSha;

        /* 2. 署名文字列(StringToSign)の生成 */
        String awsRegion = "ap-northeast-1" ;  // AWS APIリクエスト先のリージョン情報
        String awsNameSpace = "es" ;  // リクエストするAWSサービスの名前空間
        String StringToSign = "AWS4-HMAC-SHA256\n"
                            + xAmzDate + "\n"
                            + xAmzDate.substring(0, 8) + "/" + awsRegion 
                            + "/" + awsNameSpace +"/aws4_request\n"
                            + DigestUtils.sha256Hex(canonicalRequest);

        /* 3. 署名キー(SigningKey)の生成 */
        String awsSecretAccessKey = "AWSシークレットアクセスキー" ;
        // X-Amz-Date → リージョン → AWSサービス名前空間 → 固定文字(aws4_request) の順でハッシュ化していく
        String hashStr    = getHmacSha256ByStrKey("AWS4" + awsSecretAccessKey, xAmzDate.substring(0, 8));
        hashStr           = getHmacSha256ByHexKey(hashStr, awsRegion);
        hashStr           = getHmacSha256ByHexKey(hashStr, awsNameSpace);
        String SigningKey = getHmacSha256ByHexKey(hashStr, "aws4_request");

        /* 4. Authorizationヘッダー文字列の生成 */
        String awsAccessKeyId = "AWSアクセスキーID" ;
        String sig 			= getHmacSha256ByHexKey(SigningKey, StringToSign);
        String authorization = "AWS4-HMAC-SHA256 Credential="
                            + awsAccessKeyId
                            + "/" + xAmzDate.substring(0, 8)
                            + "/" + awsRegion
                            + "/" + awsNameSpace
                            + "/aws4_request,"
                            + "SignedHeaders=host;x-amz-date,"
                            + "Signature=" + sig;

        request.setHeader("Authorization", authorization);
    }

    /***  リクエストパスの取得 */
    private String getPath(HttpRequest req) throws Exception {
        String uri				= req.getRequestLine().getUri();

        // URLのクエリ文字列とのセパレータである「?」はpathに含めない
        if (uri.endsWith("?"))  uri = uri.substring(0, uri.length()-1);
        
        // URLエンコードも色々種類があるらしく、Amazonが指定したロジックでエンコードする
        return awsUriEncode(uri,true);
    }

    /*** リクスとボディのハッシュ取得 */
    private String getBodyHash(HttpRequest req) throws Exception{
        HttpEntityEnclosingRequest ereq = (HttpEntityEnclosingRequest) req;
        String body				= EntityUtils.toString(ereq.getEntity());
        return DigestUtils.sha256Hex(body);
    }

    /***
        * AWS指定スペックのURLエンコーダ
        * @param input
        * @param encodeSlash
        * @return
        * @throws UnsupportedEncodingException
        */
    private String awsUriEncode(CharSequence input, boolean encodeSlash) throws UnsupportedEncodingException {
        StringBuilder result = new StringBuilder();
        boolean queryIn = false;
        for (int i = 0; i < input.length(); i++) {
            char ch = input.charAt(i);
            if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '~' || ch == '.') {
                result.append(ch);
            } else if (ch == '/') {
                if (queryIn) result.append(encodeSlash ? "%2F" : ch);
                else result.append(ch);
            } else {
                if(!queryIn && ch=='?') {
                    queryIn = true;
                    result.append(ch);
                }else {
                    byte[] bytes = new String(new char[] {ch}).getBytes("UTF-8");
                    result.append("%" + Hex.encodeHexString(bytes,false));
                }
            }
        }
        return result.toString();
    }

    private String getHmacSha256ByStrKey(String strkey, String target) throws Exception {
        return getHmacSha256(strkey.getBytes(), target);
    }

    private String getHmacSha256ByHexKey(String hexkey, String target) throws Exception {
        return getHmacSha256(Hex.decodeHex(hexkey), target);
    }

    /***
        * target文字列をKeyを使いHMAC-SHA-256にハッシュ化する
        * @param target ハッシュ対象
        * @param key キー
        * @return Hex形式のハッシュ値
        */
    private String getHmacSha256(byte[] key, String target) throws Exception {
        final Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        return String.valueOf(Hex.encodeHex(mac.doFinal(target.getBytes()), true));
    }
}

あとがき

今回、私が実際に動作確認したのはElasticsearch APIのみですが、IAMの権限が正しく設定されていれば、他のAPIも同じ方法で利用できるかと思います。もし動作確認が取れたAWSサービスがあったら、コメントに書いていただけるとありがたいです。

お役に立ちましたら幸いです。

11
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?