RestTemplateをカスタマイズしてHTTPS通信の際にプロキシサーバに認証ヘッダを詰める
やりたいこと
クライアント(Spring Boot)からRestTemplateを用いてサーバへHTTPSのAPI呼び出しを行う、ただし間にプロキシサーバを挟んでおりそのプロキシサーバは認証用のHTTPヘッダが必要、という状況でプロキシサーバの認証を通しつつクライアントサーバ間で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
ヘッダが含まれていないことがわかります。
CONNECTでも認証ヘッダを詰める
ということでRestTemplateをカスタマイズしCONNECTリクエストに認証ヘッダが詰められるようカスタマイズをします。
先に実装(手段)を書き、その後に中身の仕組みについて解説します。
RestTemplateのカスタマイズ
dependencyにapacheの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つのクラスを用意します。
- HttpClientBuilderを拡張したクラス
- 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") }
がリクエストにヘッダを詰める処理となります。
そして、このheaderCustomizeHttpProcessor
をsuper.createMainExec
のproxyHttpProcessor
として渡しています。
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ヘッダが埋められていることを確認できました。
仕組み
さて、上記で課題は解決出来たのですが最初は正直「なんで上手くいくの?」状態でした。自分たちで用意したHttpProcessorが使われているであろうことはわかったけど内部の繋がりが理解できていません。
解説するのは難しいですが、中の仕組みを紐解いていきます。(ここからはライブラリ内の話になるのでソースコードはJavaになります)
RestTemplateにおけるRequestFactoryの使われ方
まずは今回差し替えたRequestFactoryについて。これはどのようなものでしょうか。 TERASOLUNAのRestTemplateの解説ページがわかりやすく記載されているので引用します。(Overviewに記載されている図もとてもわかりやすいです)
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