よーぐるとのブログ

技術ネタを中心。私的なことを徒然と綴っていきます。

RestTemplateをカスタマイズしてHTTPS通信の際にプロキシサーバに認証ヘッダを詰める

やりたいこと

クライアント(Spring Boot)からRestTemplateを用いてサーバへHTTPSAPI呼び出しを行う、ただし間にプロキシサーバを挟んでおりそのプロキシサーバは認証用のHTTPヘッダが必要、という状況でプロキシサーバの認証を通しつつクライアントサーバ間でHTTPS通信を実現したい、というのが本稿のお題になります。

proxy-server-auth-architecture
構成。HTTPS通信時にプロキシサーバに独自の認証ヘッダを付与したい。

課題

通常、HTTPSでの通信を行う場合、間にあるプロキシサーバは中身を見ることが出来ないためCONNECTメソッドのリクエストを用いて通信のトンネル化を行います。しかし、プロキシサーバに認証がかかっている場合、CONNECTリクエストに認証用のヘッダを詰めてあげないといけません。そして、RestTemplateで普通にプロキシサーバの設定をしてHTTPS通信をする場合、CONNECTリクエストには本体のリクエスト作成時に詰めた認証ヘッダは付与されません。

// こういう感じだとうまく行かない
fun call() {
    var restTemplate = RestTemplateBuilder().build()
    val proxyHost = "proxy"
    val proxyPort = 8888
    val address = InetSocketAddress(proxyHost, proxyPort)

    // プロキシ設定
    var requestFactory = SimpleClientHttpRequestFactory()
    val proxy = Proxy(Proxy.Type.HTTP, address)
    requestFactory.setProxy(proxy)
    restTemplate.requestFactory = requestFactory

    // ここで認証ヘッダを詰めてもCONNECTリクエストには反映されない
    var headers = HttpHeaders()
    headers.add("X-AUTH", "authValue")
    val requestEntity = RequestEntity<Request>(Request(), headers, HttpMethod.GET, URI("https://server"))
    restTemplate.exchange(requestEntity, Response::class.java)
}

実際に上記のようなリクエストを実行してWiresharkでキャプチャしてみると、CONNECTリクエストにはX-AUTHヘッダが含まれていないことがわかります。

capture-wireshark-init
Wiresharkのキャプチャ結果.X-AUTHが含まれていない.

CONNECTでも認証ヘッダを詰める

ということでRestTemplateをカスタマイズしCONNECTリクエストに認証ヘッダが詰められるようカスタマイズをします。

先に実装(手段)を書き、その後に中身の仕組みについて解説します。

RestTemplateのカスタマイズ

dependencyapacheのhttpclientを追加します。

<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.12</version>
</dependency>

そして、RestTemplateをカスタマイズするために以下の2つのクラスを用意します。

  1. HttpClientBuilderを拡張したクラス
  2. ClientHttpRequestFactoryを生成するビルダークラス

HttpClientBuilderを拡張したクラス

これが今回の肝になります。HttpClientBuilderのcreateMainExecメソッドをオーバーライドしたクラスを作成します。

import org.apache.http.ConnectionReuseStrategy
import org.apache.http.HttpRequest
import org.apache.http.HttpRequestInterceptor
import org.apache.http.client.AuthenticationStrategy
import org.apache.http.client.UserTokenHandler
import org.apache.http.conn.ConnectionKeepAliveStrategy
import org.apache.http.conn.HttpClientConnectionManager
import org.apache.http.impl.client.HttpClientBuilder
import org.apache.http.impl.execchain.ClientExecChain
import org.apache.http.protocol.HttpProcessor
import org.apache.http.protocol.HttpRequestExecutor
import org.apache.http.protocol.ImmutableHttpProcessor
import org.apache.http.protocol.RequestTargetHost

class HeaderCustomizeHttpClientBuilder : HttpClientBuilder() {

    companion object {
        fun create(): HttpClientBuilder {
            return HeaderCustomizeHttpClientBuilder()
        }
    }

    override fun createMainExec(requestExec: HttpRequestExecutor?, connManager: HttpClientConnectionManager?,
                                reuseStrategy: ConnectionReuseStrategy?, keepAliveStrategy: ConnectionKeepAliveStrategy?,
                                proxyHttpProcessor: HttpProcessor?, targetAuthStrategy: AuthenticationStrategy?,
                                proxyAuthStrategy: AuthenticationStrategy?, userTokenHandler: UserTokenHandler?): ClientExecChain {

        val headerCustomizeHttpProcessor = ImmutableHttpProcessor(
                RequestTargetHost(),
                HttpRequestInterceptor { request: HttpRequest, _ -> request.addHeader("X-AUTH", "authValue") }
        )
        return super.createMainExec(requestExec, connManager, reuseStrategy, keepAliveStrategy,
                /* customized processor*/ headerCustomizeHttpProcessor, targetAuthStrategy,
                proxyAuthStrategy, userTokenHandler)
    }
}

headerCustomizeHttpProcessorを生成しているところに注目してください。この第2引数に渡している

 HttpRequestInterceptor { request: HttpRequest, _ -> request.addHeader("X-AUTH", "authValue") }

がリクエストにヘッダを詰める処理となります。

そして、このheaderCustomizeHttpProcessorsuper.createMainExecproxyHttpProcessorとして渡しています。

ClientHttpRequestFactoryを生成するビルダー

次に、上記にビルダーを受け取ってRequestFactoryを返すビルダーを実装します。

import org.apache.http.HttpHost
import org.springframework.http.client.ClientHttpRequestFactory
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory

class ProxyClientHttpRequestFactoryBuilder(private val httpHost: HttpHost) {
    fun build(): ClientHttpRequestFactory {
        var httpClientBuilder = HeaderCustomizeHttpClientBuilder.create()
        httpClientBuilder.setProxy(httpHost)
        return HttpComponentsClientHttpRequestFactory(httpClientBuilder.build())
    }
}

ここでは先程のHeaderCustomizeHttpClientBuilderから作成したHttpClientBuilderにプロキシをセットしてからHttpClientを.build()し、HttpComponentsClientHttpRequestFactoryクラスに渡しています。

RestTemplateのカスタマイズ

それでは上記の2つのクラスをもとに最初の例ではRestTemplateBuilder().build()するだけだったRestTemplateをカスタマイズします。

fun callCustomized() {
    val proxyHost = "proxy"
    val proxyPort = 8888
    val requestFactoryBuilder = ProxyClientHttpRequestFactoryBuilder(HttpHost(proxyHost, proxyPort))
    var restTemplate = RestTemplateBuilder()
            .requestFactory{ requestFactoryBuilder.build() }
            .build()

    var headers = HttpHeaders()
    val requestEntity = RequestEntity<Request>(Request(), headers, HttpMethod.GET, URI("https://server"))
    restTemplate.exchange(requestEntity, Response::class.java)
}

このようにしてあげると、リクエストをする際にHeaderCustomizeHttpClientBuilderにてセットされたHttpProcessorが利用され、CONNECTリクエストに認証用のヘッダを詰めることができるようになります。

実際にWiresharkでも、X-AUTHヘッダが埋められていることを確認できました。

capture-wireshark-with-authheader
カスタマイズ後のCONECTメソッドのキャプチャ. 認証ヘッダが含まれている.

仕組み

さて、上記で課題は解決出来たのですが最初は正直「なんで上手くいくの?」状態でした。自分たちで用意したHttpProcessorが使われているであろうことはわかったけど内部の繋がりが理解できていません。

解説するのは難しいですが、中の仕組みを紐解いていきます。(ここからはライブラリ内の話になるのでソースコードJavaになります)

RestTemplateにおけるRequestFactoryの使われ方

まずは今回差し替えたRequestFactoryについて。これはどのようなものでしょうか。 TERASOLUNAのRestTemplateの解説ページがわかりやすく記載されているので引用します。(Overviewに記載されている図もとてもわかりやすいです)

5.17. RESTクライアント(HTTPクライアント) — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.1.0.RELEASE documentation

RestTemplateは、サーバとの通信処理を以下の3つのインタフェースの実装クラスに委譲することで実現している。

  • org.springframework.http.client.ClientHttpRequestFactory
  • org.springframework.http.client.ClientHttpRequest
  • org.springframework.http.client.ClientHttpResponse

この3つのインタフェースのうち、開発者が意識するのはClientHttpRequestFactoryである。 ClientHttpRequestFactoryは、サーバとの通信処理を行うクラス(ClientHttpRequestと ClientHttpResponseインタフェースの実装クラス)を解決する役割を担っている。

実際にClientHttpRequestFactoryインターフェースを見てみると以下のようにClientHttpRequestを生成するメソッドが用意されています。

@FunctionalInterface
public interface ClientHttpRequestFactory {
    ClientHttpRequest createRequest(URI var1, HttpMethod var2) throws IOException;
}

設定がない場合、この実装クラスはorg.springframework.http.client.SimpleClientHttpRequestFactoryが選ばれます。

一方で今回利用したHttpComponentsClientHttpRequestFactoryクラスのcreateRequestを見てみましょう。

    public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
        HttpClient client = this.getHttpClient();
        HttpUriRequest httpRequest = this.createHttpUriRequest(httpMethod, uri);
        this.postProcessHttpRequest(httpRequest);
        HttpContext context = this.createHttpContext(httpMethod, uri);
        if (context == null) {
            context = HttpClientContext.create();
        }

        if (((HttpContext)context).getAttribute("http.request-config") == null) {
            RequestConfig config = null;
            if (httpRequest instanceof Configurable) {
                config = ((Configurable)httpRequest).getConfig();
            }

            if (config == null) {
                config = this.createRequestConfig(client);
            }

            if (config != null) {
                ((HttpContext)context).setAttribute("http.request-config", config);
            }
        }

        return (ClientHttpRequest)(this.bufferRequestBody ? new HttpComponentsClientHttpRequest(client, httpRequest, (HttpContext)context) : new HttpComponentsStreamingClientHttpRequest(client, httpRequest, (HttpContext)context));
    }

client = this.getHttpClient();はHttpComponentsClientHttpRequestFactoryのコンストラクタで渡されたHttpClientで、今回作成したHttpClientBuilderから作られたものが渡ってきています。 そして中では色々やっていますが最終的にHttpComponentsClientHttpRequestのインスタンスにclientごと渡しています。

HttpComponentsClientHttpRequestを見てみましょう。

final class HttpComponentsClientHttpRequest extends AbstractBufferingClientHttpRequest {
    private final HttpClient httpClient;
    private final HttpUriRequest httpRequest;
    private final HttpContext httpContext;

    HttpComponentsClientHttpRequest(HttpClient client, HttpUriRequest request, HttpContext context) {
        this.httpClient = client;
        this.httpRequest = request;
        this.httpContext = context;
    }
    // -------中略-------
    // -------中略-------
    protected ClientHttpResponse executeInternal(HttpHeaders headers, byte[] bufferedOutput) throws IOException {
        addHeaders(this.httpRequest, headers);
        if (this.httpRequest instanceof HttpEntityEnclosingRequest) {
            HttpEntityEnclosingRequest entityEnclosingRequest = (HttpEntityEnclosingRequest)this.httpRequest;
            HttpEntity requestEntity = new ByteArrayEntity(bufferedOutput);
            entityEnclosingRequest.setEntity(requestEntity);
        }

        HttpResponse httpResponse = this.httpClient.execute(this.httpRequest, this.httpContext);
        return new HttpComponentsClientHttpResponse(httpResponse);
    }

}

executeInternal()メソッドの内部でthis.httpClient.execute(this.httpRequest, this.httpContext);されているのがわかります。そのためここで自前で用意したHttpClientのexecuteメソッドが呼ばれていることがわかりました。

また、このexecuteInternal()は親クラスであるAbstractClientHttpRequestにて呼ばれています。

public abstract class AbstractClientHttpRequest implements ClientHttpRequest {
    // -------中略-------
    // -------中略-------
    public final ClientHttpResponse execute() throws IOException {
        this.assertNotExecuted();
        ClientHttpResponse result = this.executeInternal(this.headers);
        this.executed = true;
        return result;
    }

そしてこのexecuteメソッドは何かというと....

public interface ClientHttpRequest extends HttpRequest, HttpOutputMessage {
    ClientHttpResponse execute() throws IOException;
}

ClientHttpRequestのexecute()になります。RestTemplateで実際にリクエストを送信するのはこのメソッドになるので、RequestFactoryを差し替えることで自前のHttpClientが呼ばれていそう、ということがわかります。

createMainExecの呼び出し

HeaderCustomizeHttpClientBuilderクラスではHttpClientBuilder.createMainExec()メソッドをオーバーライドしました。 このcreateMainExec()メソッドは、HttpClientBuilder.build()メソッドの中で登場します。

public class HttpClientBuilder {
    // -------中略-------
    // -------中略-------

    public CloseableHttpClient build() {
    
    // -------中略-------
    // -------中略-------
    
        ClientExecChain execChain = createMainExec(
                requestExecCopy,
                connManagerCopy,
                reuseStrategyCopy,
                keepAliveStrategyCopy,
                new ImmutableHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
                targetAuthStrategyCopy,
                proxyAuthStrategyCopy,
                userTokenHandlerCopy);
    // -------中略-------


        return new InternalHttpClient(
                execChain,
                connManagerCopy,
                routePlannerCopy,
                cookieSpecRegistryCopy,
                authSchemeRegistryCopy,
                defaultCookieStore,
                defaultCredentialsProvider,
                defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,
                closeablesCopy);
    }

createMainExec()の第5引数であるproxyHttpProcessorは、HttpClientBulder.build()メソッドの中でnew ImmutableHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),インスタンスが渡されています。 今回はここのImmutableHttpProcessorを、引数で渡されたインスタンスを無視して差し替えた形になります。

       // 今回差し替えたImmutableHttpProcessor
        val headerCustomizeHttpProcessor = ImmutableHttpProcessor(
                RequestTargetHost(),
                HttpRequestInterceptor { request: HttpRequest, _ -> request.addHeader("X-AUTH", "authValue") }
        )
        return super.createMainExec(requestExec, connManager, reuseStrategy, keepAliveStrategy,
                /* customized processor*/ headerCustomizeHttpProcessor, targetAuthStrategy,
                proxyAuthStrategy, userTokenHandler)
    }
}

そして、差し替えたImmutableHttpProcessorを持つClientExecChainはHttpClientBuilder.build()メソッドの最後でInternalHttpClientのインスタンスを生成する際に渡されています。

ImmutableHttpProcessorが実行されるためのMainClientExec.execute()メソッドはこのInternalHttpClientのdoExecute()メソッド内で呼ばれます。(ImmutableHttpProcessorが実行されるところは後述します。)

class InternalHttpClient extends CloseableHttpClient implements Configurable {

    // -------中略-------

    @Override
    protected CloseableHttpResponse doExecute(
            final HttpHost target,
            final HttpRequest request,
            final HttpContext context) throws IOException, ClientProtocolException {

            // -------中略-------

            return this.execChain.execute(route, wrapper, localcontext, execAware);
    }
}

そして(長い....)このInternalHttpClientのdoExecute()がどこで呼ばれるかというと...

public abstract class CloseableHttpClient implements HttpClient, Closeable {

    private final Log log = LogFactory.getLog(getClass());

    protected abstract CloseableHttpResponse doExecute(HttpHost target, HttpRequest request,
            HttpContext context) throws IOException, ClientProtocolException;

    /**
     * {@inheritDoc}
     */
    @Override
    public CloseableHttpResponse execute(
            final HttpHost target,
            final HttpRequest request,
            final HttpContext context) throws IOException, ClientProtocolException {
        return doExecute(target, request, context);
    }

    // -------中略-------

}

CloseableHttpClientのexecute()メソッド、すなわちHttpClientのexecute()メソッドになります。

HttpClientのexecute()は、HttpComponentsClientHttpRequestのexecuteInternal()で呼ばれていました!

(再掲)

final class HttpComponentsClientHttpRequest extends AbstractBufferingClientHttpRequest {

    // -------中略-------

    protected ClientHttpResponse executeInternal(HttpHeaders headers, byte[] bufferedOutput) throws IOException {
        addHeaders(this.httpRequest, headers);
        // -------中略-------
        HttpResponse httpResponse = this.httpClient.execute(this.httpRequest, this.httpContext);
        return new HttpComponentsClientHttpResponse(httpResponse);
    }
}

ようやく繋がりました。すなわち、今回作成したHeaderCustomizeHttpClientBuilder,ProxyClientHttpRequestFactoryBuilderの2つはRestTemplateで呼ばれるHttpClient(実態はInternalHttpClient)の、execte()内のdoExecute()で呼出されるClientExecChain.execute()を拡張していることになります。書いているだけで舌を噛みそうですね。

さて、それでは最後にMainClientExecでImmutableHttpProcessorが処理されている場所を見ていきます。

CONNECTリクエストが生成される場所

MainClientExec.execute()の中を紐解いていきます。メソッド内のコネクションを確立するメソッドestablishRoute()に着目します。

public class MainClientExec implements ClientExecChain {

    // -------中略-------

    @Override
    public CloseableHttpResponse execute(
            final HttpRoute route,
            final HttpRequestWrapper request,
            final HttpClientContext context,
            final HttpExecutionAware execAware) throws IOException, HttpException {

               // -------中略-------

                if (!managedConn.isOpen()) {
                    this.log.debug("Opening connection " + route);
                    try {
                        establishRoute(proxyAuthState, managedConn, route, request, context);
                    } catch (final TunnelRefusedException ex) {

               // -------中略-------

    }

establishRoute()内では、まさに最初に説明したProxyサーバを挟んだ場合にHTTPS通信時の動きが実装されています。

    void establishRoute(
            final AuthState proxyAuthState,
            final HttpClientConnection managedConn,
            final HttpRoute route,
            final HttpRequest request,
            final HttpClientContext context) throws HttpException, IOException {
        final RequestConfig config = context.getRequestConfig();
        final int timeout = config.getConnectTimeout();
        final RouteTracker tracker = new RouteTracker(route);
        int step;
        do {
            final HttpRoute fact = tracker.toRoute();
            step = this.routeDirector.nextStep(route, fact);

            switch (step) {

            case HttpRouteDirector.CONNECT_TARGET:
                this.connManager.connect(
                        managedConn,
                        route,
                        timeout > 0 ? timeout : 0,
                        context);
                tracker.connectTarget(route.isSecure());
                break;
            case HttpRouteDirector.CONNECT_PROXY:
                this.connManager.connect(
                        managedConn,
                        route,
                        timeout > 0 ? timeout : 0,
                        context);
                final HttpHost proxy  = route.getProxyHost();
                tracker.connectProxy(proxy, route.isSecure() && !route.isTunnelled());
                break;
            case HttpRouteDirector.TUNNEL_TARGET: {
                final boolean secure = createTunnelToTarget(
                        proxyAuthState, managedConn, route, request, context);
                this.log.debug("Tunnel to target created.");
                tracker.tunnelTarget(secure);
            }   break;

            case HttpRouteDirector.TUNNEL_PROXY: {
                // The most simple example for this case is a proxy chain
                // of two proxies, where P1 must be tunnelled to P2.
                // route: Source -> P1 -> P2 -> Target (3 hops)
                // fact:  Source -> P1 -> Target       (2 hops)
                final int hop = fact.getHopCount()-1; // the hop to establish
                final boolean secure = createTunnelToProxy(route, hop, context);
                this.log.debug("Tunnel to proxy created.");
                tracker.tunnelProxy(route.getHopTarget(hop), secure);
            }   break;

            case HttpRouteDirector.LAYER_PROTOCOL:
                this.connManager.upgrade(managedConn, route, context);
                tracker.layerProtocol(route.isSecure());
                break;

            case HttpRouteDirector.UNREACHABLE:
                throw new HttpException("Unable to establish route: " +
                        "planned = " + route + "; current = " + fact);
            case HttpRouteDirector.COMPLETE:
                this.connManager.routeComplete(managedConn, route, context);
                break;
            default:
                throw new IllegalStateException("Unknown step indicator "
                        + step + " from RouteDirector.");
            }

        } while (step > HttpRouteDirector.COMPLETE);
    }

受け取ったHttpRoute(中身は {tls} http://proxy:8888 -> https://server:443というような表現になっています)をもとに、RouteDirector.nextStepでもらった処理をdo~whileブロック内で実行していきます。

最初はCONNECT_PROXY、プロキシサーバとのコネクション確立が実行されます。その次にトンネル化の処理TUNNEL_TARGETが実行されます。このTUNNEL_TARGETで呼び出されているcreateTunnelToTarget()メソッドがトンネル化、すなわちCONNECTリクエストを投げている部分になります。

    /**
     * Creates a tunnel to the target server.
     * The connection must be established to the (last) proxy.
     * A CONNECT request for tunnelling through the proxy will
     * be created and sent, the response received and checked.
     * This method does <i>not</i> update the connection with
     * information about the tunnel, that is left to the caller.
     */
    private boolean createTunnelToTarget(
            final AuthState proxyAuthState,
            final HttpClientConnection managedConn,
            final HttpRoute route,
            final HttpRequest request,
            final HttpClientContext context) throws HttpException, IOException {

        final RequestConfig config = context.getRequestConfig();
        final int timeout = config.getConnectTimeout();

        final HttpHost target = route.getTargetHost();
        final HttpHost proxy = route.getProxyHost();
        HttpResponse response = null;

        final String authority = target.toHostString();
        final HttpRequest connect = new BasicHttpRequest("CONNECT", authority, request.getProtocolVersion());

        this.requestExecutor.preProcess(connect, this.proxyHttpProcessor, context);

        while (response == null) {
            if (!managedConn.isOpen()) {
                this.connManager.connect(
                        managedConn,
                        route,
                        timeout > 0 ? timeout : 0,
                        context);
            }

            connect.removeHeaders(AUTH.PROXY_AUTH_RESP);
            this.authenticator.generateAuthResponse(connect, proxyAuthState, context);

            response = this.requestExecutor.execute(connect, managedConn, context);

            // ------- 省略-------

この中の2行

final HttpRequest connect = new BasicHttpRequest("CONNECT", authority, request.getProtocolVersion());
this.requestExecutor.preProcess(connect, this.proxyHttpProcessor, context);

の部分でCONNECTリクエストを作成し、requestExecutor.preProcessメソッドにて今回作成したproxyHttpProcessorが渡されています。preProcess内では渡されたproxyHttpProcessor内のHttpRequestInterceptorが順に実行されていき、ようやくここでX-AUTHヘッダがリクエストにセットされます。ここまで来ないとCONNECT用のリクエスインスタンスが作られないため、もとのリクエストでセットされたヘッダはここまで渡ってきません。HttpClientBuilderのcreateMainExecをオーバーライドすることで初めてProxyサーバに送るCONNECTリクエストに独自の実装を行うことができたのです。

まとめ

SpringのRestTemplateを用いて、ProxyサーバへのCONNECTリクエスト作成時に独自の認証ヘッダを挿し込む方法を紹介しました。実際の実現方法だけでなくRestTemplate、HttpClientの中身を見ていくことで今まで意識していなかった内部の動きについても詳しくなった気がします。

また、今回実装したクラスは ↓に上がっています。 github.com