前回、Spring Boot 2とOAuth2 Autoconfigで超シンプルにOAuth2クライアントを実現する方法を解説しましたが、作成したクライアントアプリをプロキシ環境で利用する場合にはOAuth2RestTemplate
がプロキシを経由するようセットアップする必要があります。
しかし、プロキシを適用する方法が意外と面倒で、まとまっているサイトもなかったので、参考程度にまとめておきます。
今回使用するライブラリ
- Spring Boot 2 (Spring & Spring Security 5)
- spring-boot-starter-web 2.0.1.RELEASE
- spring-boot-starter-security 2.0.1.RELEASE
- OAuth2 Autoconfig (Spring Security OAuth2)
- spring-security-oauth2-autoconfigure 2.0.0.RELEASE
※前回記事と同じです。
OAuth2RestTemplate
にプロキシを適用する
OAuth2RestTemplate
はRestTemplate
を拡張しており、プロキシを適用する方法も同様です。
- 認証なしプロキシを利用する場合
@Bean
public ClientHttpRequestFactory requestFactory(
@Value("${proxy.host}") String host,
@Value("${proxy.port}") int port) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setProxy(new Proxy(Type.HTTP, new InetSocketAddress(host, port)));
return factory;
}
@Bean
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
OAuth2ProtectedResourceDetails details, ClientHttpRequestFactory requestFactory) {
OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
restTemplate.setRequestFactory(requestFactory);
return restTemplate;
}
- 認証プロキシを利用する場合
@Bean
public ClientHttpRequestFactory requestFactory(
@Value("${proxy.host}") String host,
@Value("${proxy.port}") int port,
@Value("${proxy.user}") String user,
@Value("${proxy.password}") String password) {
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setProxy(new HttpHost(host, port));
if (StringUtils.hasText(user) && StringUtils.hasText(password)) {
BasicCredentialsProvider provider = new BasicCredentialsProvider();
provider.setCredentials(new AuthScope(host, port), new UsernamePasswordCredentials(user, password));
builder.setDefaultCredentialsProvider(provider);
}
return new HttpComponentsClientHttpRequestFactory(builder.build());
}
@Bean
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
OAuth2ProtectedResourceDetails details, ClientHttpRequestFactory requestFactory) {
OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
restTemplate.setRequestFactory(requestFactory);
return restTemplate;
}
今回は、認証プロキシを利用するパターンで検証してみました。
プロキシが適切に適用されない
作成したクライアントアプリに上記の方法で認証プロキシを適用し、SSOログインしてみると、、、
- コンテキストルート(
/
)にアクセス - ログインURL(
/login
)にリダイレクト - Githubの認証URLにリダイレクト
- 認可コードを付与してログインURL(
/login
)にリダイレクト - Githubの認証URLにリダイレクト
- 認可コードを付与してログインURL(
/login
)にリダイレクト - ...
と、延々認証をループしていまいました。
Spring Security OAuth2のロガーをINFOレベルにしてログを確認すると、407 Proxy Authentication Requiredと出力されており、認証プロキシが適切に適用されていないことが分かります。
これは、@EnableOAuth2Sso
でセットアップされるOAuth2ClientAuthenticationProcessingFilter
では、Bean定義したOAuth2RestTemplate
とは別のOAuth2RestTemplate
が使用されるためです。
@EnableOAuth2Sso
でセットアップされるOAuth2RestTemplate
にプロキシを適用する(解析編)
ここで、@EnableOAuth2Sso
によりセットアップされるコンポーネントを図示します。
これにより、@EnableOAuth2Sso
により複数のOAuth2RestTemplate
が生成されますが、そのすべてがUserInfoRestTemplateFactory
によって生成(または生成されたOAuth2RestTemplate
を通じて生成)されていることが分かります。
次に、UserInfoRestTemplateFactory
インターフェイスのデフォルト実装クラス(DefaultUserInfoRestTemplateFactory
)を確認します。
public class DefaultUserInfoRestTemplateFactory implements UserInfoRestTemplateFactory {
// omitted.
public DefaultUserInfoRestTemplateFactory(
ObjectProvider<List<UserInfoRestTemplateCustomizer>> customizers,
ObjectProvider<OAuth2ProtectedResourceDetails> details,
ObjectProvider<OAuth2ClientContext> oauth2ClientContext) {
this.customizers = customizers.getIfAvailable();
this.details = details.getIfAvailable();
this.oauth2ClientContext = oauth2ClientContext.getIfAvailable();
}
// omitted.
@Override
public OAuth2RestTemplate getUserInfoRestTemplate() {
if (this.oauth2RestTemplate == null) {
this.oauth2RestTemplate = createOAuth2RestTemplate(
this.details == null ? DEFAULT_RESOURCE_DETAILS : this.details);
this.oauth2RestTemplate.getInterceptors()
.add(new AcceptJsonRequestInterceptor());
AuthorizationCodeAccessTokenProvider accessTokenProvider = new AuthorizationCodeAccessTokenProvider();
accessTokenProvider.setTokenRequestEnhancer(new AcceptJsonRequestEnhancer());
this.oauth2RestTemplate.setAccessTokenProvider(accessTokenProvider);
if (!CollectionUtils.isEmpty(this.customizers)) {
AnnotationAwareOrderComparator.sort(this.customizers);
for (UserInfoRestTemplateCustomizer customizer : this.customizers) {
customizer.customize(this.oauth2RestTemplate); // ★★★
}
}
}
return this.oauth2RestTemplate;
}
// omitted.
}
注目すべきは、UserInfoRestTemplateCustomizer#customize
メソッドにより、OAuth2RestTemplate
に任意の設定を追加できる点です。
次に、独自にOAuth2RestTemplate
を生成しているAccessTokenProvider
の抽象クラス(OAuth2AccessTokenSupport
)を確認します。
public abstract class OAuth2AccessTokenSupport {
// omitted.
protected RestOperations getRestTemplate() {
if (restTemplate == null) {
synchronized (this) {
if (restTemplate == null) {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(getResponseErrorHandler());
restTemplate.setRequestFactory(requestFactory); // ★★★
restTemplate.setInterceptors(interceptors);
this.restTemplate = restTemplate;
}
}
}
if (messageConverters == null) {
setMessageConverters(new RestTemplate().getMessageConverters());
}
return restTemplate;
}
// omitted.
public void setRequestFactory(ClientHttpRequestFactory requestFactory) {
Assert.notNull(requestFactory, "'requestFactory' must not be null");
this.requestFactory = requestFactory;
}
// omitted.
}
注目すべきは、冒頭で説明したClientHttpRequestFactory
により、内部的に生成するOAuth2RestTemplate
にプロキシを適用できる点です。
ちなみに、
OAuth2AccessTokenSupport
ではデフォルトで適用するClientHttpRequestFactory
のprepareConnection
メソッドをオーバーライドしており、同様にオーバーライドしたほうが良いかもしれません。(細かく検証していないので、問題になるパターンには遭遇していませんが。)
@EnableOAuth2Sso
でセットアップされるOAuth2RestTemplate
にプロキシを適用する(実装編)
解析結果から、@EnableOAuth2Sso
によりセットアップされるすべてのOAuth2RestTemplate
に認証プロキシを適用します。
// (1) リクエストにプロキシ認証情報を組み込むClientHttpRequestFactoryを生成する
@Bean
@ConditionalOnProperty("proxy.host")
public ClientHttpRequestFactory requestFactory(
@Value("${proxy.host}") String host,
@Value("${proxy.port}") int port,
@Value("${proxy.user}") String user,
@Value("${proxy.password}") String password) {
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setProxy(new HttpHost(host, port));
if (StringUtils.hasText(user) && StringUtils.hasText(password)) {
BasicCredentialsProvider provider = new BasicCredentialsProvider();
provider.setCredentials(new AuthScope(host, port), new UsernamePasswordCredentials(user, password));
builder.setDefaultCredentialsProvider(provider);
}
return new HttpComponentsClientHttpRequestFactory(builder.build());
}
// (2) 認証プロキシ用ClientHttpRequestFactoryをセットしたAccessTokenProviderを生成する
@Bean
@ConditionalOnBean(ClientHttpRequestFactory.class)
public AccessTokenProvider accessTokenProvider(ClientHttpRequestFactory requestFactory) {
AuthorizationCodeAccessTokenProvider authorizationCodeAccessTokenProvider = new AuthorizationCodeAccessTokenProvider();
authorizationCodeAccessTokenProvider.setRequestFactory(requestFactory);
ImplicitAccessTokenProvider implicitAccessTokenProvider = new ImplicitAccessTokenProvider();
implicitAccessTokenProvider.setRequestFactory(requestFactory);
ResourceOwnerPasswordAccessTokenProvider resourceOwnerPasswordAccessTokenProvider = new ResourceOwnerPasswordAccessTokenProvider();
resourceOwnerPasswordAccessTokenProvider.setRequestFactory(requestFactory);
ClientCredentialsAccessTokenProvider clientCredentialsAccessTokenProvider = new ClientCredentialsAccessTokenProvider();
clientCredentialsAccessTokenProvider.setRequestFactory(requestFactory);
return new AccessTokenProviderChain(
Arrays.asList(authorizationCodeAccessTokenProvider, implicitAccessTokenProvider,
resourceOwnerPasswordAccessTokenProvider, clientCredentialsAccessTokenProvider));
}
// (3) UserInfoRestTemplateCustomizerを利用して、OAuth2RestTemplateに認証プロキシ用ClientHttpRequestFactoryとAccessTokenProviderをセットする
@Bean
@ConditionalOnBean({ AccessTokenProvider.class, ClientHttpRequestFactory.class })
public UserInfoRestTemplateCustomizer userInfoRestTemplateCustomizer(AccessTokenProvider accessTokenProvider,
ClientHttpRequestFactory requestFactory) {
return new UserInfoRestTemplateCustomizer() {
@Override
public void customize(OAuth2RestTemplate restTemplate) {
restTemplate.setAccessTokenProvider(accessTokenProvider);
restTemplate.setRequestFactory(requestFactory);
}
};
}
// (4) Controller等から直接利用するOAuth2RestTemplateにも、認証プロキシ用ClientHttpRequestFactoryとAccessTokenProviderをセットする
@Bean
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
OAuth2ProtectedResourceDetails details, ObjectProvider<AccessTokenProvider> accessTokenProvider,
ObjectProvider<ClientHttpRequestFactory> requestFactory) {
OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
accessTokenProvider.ifAvailable(restTemplate::setAccessTokenProvider);
requestFactory.ifAvailable(restTemplate::setRequestFactory);
return restTemplate;
}
今回の実装では、プロキシ環境とパブリック環境の両方で利用できるよう、
@ConditionalOnProperty
と@ConditionalOnBean
により生成するBeanを切り替えられるようにしています。
また、ObjectProvider
は引数で受け取るBeanが存在しない場合や複数ある場合に対応するためのラッパーで、Java 8のOptional
のような役割を果たします。
修正したクライアントアプリで改めてSSOログインしてみると、、、
- コンテキストルート(
/
)にアクセス - ログインURL(
/login
)にリダイレクト - Githubの認証URLにリダイレクト
- 認可コードを付与してログインURL(
/login
)にリダイレクト - コンテキストルートが表示される
と、正常にログインできました。
まとめ
Autoconfigがデフォルトでプロキシを考慮していないことはしょうがないのですが、意外なほどOAuth2RestTemplate
を使い回しておらず、設定したはずなのに何故動かないんだろう?と躓いてしまいました。。。
公式リファレンスで「OAuth2クライアントではOAuth2RestTemplate
をBean定義して使え」と記載しているのに、Bean定義したものが利用されない場合があるのは直感的ではなく、ちょっと不親切ですね。今後改善されるといいなーと思います。