Tagbangers Blog

ELB と Spring Security の RequiresChannelUrl#requiresSecure

The problem

Spring Security には 通信を HTTPS に強制的にリダイレクトする機能があります。
以下はその設定例。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
				.antMatchers("/**").hasRole("USER")
				.and()
			.formLogin()
				.and()
			.requiresChannel()
				.anyRequest().requiresSecure();
	}
	...

このように設定することでプロトコルが HTTP の場合に HTTPS にリダイレクトしてくれるようになります。

便利な設定ですが、Spring が動作する Tomcat 等の前段に ELB (Elasitc Load Balancing) などの SSL アクセラレータ機能のあるロードバランサが存在すると、クライアント <-> ELB 間は SSL 通信ですが、ELB <-> Tomcat 間は HTTP 通信になり、クライアントからの通信が SSL であったとしても HTTP と誤認してしまい、常に HTTPS にリダイレクトしようとしてしまいます。

The approach

このような状況を解決するために、次のようなアプローチが考えられます。

1. Spring Security のプロトコル判定ロジックをだます 
2. Spring Security のプロトコル判定ロジックを変更する

ではそもそも Spring Security はどのようなロジックで通信が HTTP なのか SSL なのかを判断しているのでしょうか。
次のコードがその部分にあたります。

SecureChannelProcessor の抜粋

package org.springframework.security.web.access.channel;
...
public class SecureChannelProcessor implements InitializingBean, ChannelProcessor {
	...
	public void decide(FilterInvocation invocation, Collection<ConfigAttribute> config) throws IOException, ServletException {
		Assert.isTrue((invocation != null) && (config != null), "Nulls cannot be provided");
		for (ConfigAttribute attribute : config) {
			if (supports(attribute)) {
				if (!invocation.getHttpRequest().isSecure()) {
					entryPoint.commence(invocation.getRequest(), invocation.getResponse());
				}
			}
		}
	}
	...

つまり Spring Security は HttpServletRequest#isSecure の結果をみて SSL 通信かどうかを判定しています。

Spring Security のプロトコル判定ロジックをだます

要はミドルウェア層で解決しようというもの。
もし、Tomcat で動作させている場合は、Tomcat の Server.xml の Connector 設定で secure="true" とすれば、HttpServletRequest#isSecure の戻り値を true にできる。

<Connector port="8080" protocol="HTTP/1.1"
            proxyPort="443" scheme="https" secure="true"
            proxyName="myapp.example.com"
            connectionTimeout="20000"
            URIEncoding="UTF-8"
            redirectPort="8443" />

Spring Security のプロトコル判定ロジックを変更する

Spring Security の判定ロジックそものを変更してしまおうといもの。
具体的には、X-Forwarded-Proto ヘッダを使って通信プロコトルを判断する、 SecureChannelProcessor と InsecureChannelProcessor のサブクラスを実装します。

Elastic Load Balancing - X-Forwarded-Proto について

X-Forwarded-Proto

X-Forwarded-Proto リクエストヘッダーを使用すると、クライアントがサーバーへの接続に使用したプロトコル(HTTP または HTTPS)を識別することができます。サーバーアクセスログには、サーバーとロードバランサーの間で使用されたプロトコルのみが含まれ、クライアントとロードバランサーの間で使用されたプロトコルに関する情報は含まれません。クライアントとロードバランサーの間で使用されたプロトコルを判別するには、X-Forwarded-Proto リクエストヘッダーを使用します。Elastic Load Balancing は、クライアントとロードバランサーの間で使用されたプロトコルを X-Forwarded-Proto リクエストヘッダーに格納し、このヘッダーをサーバーに渡します。

SecureChannelProcessor のサブクラスの実装例

public class ProxyInsecureChannelProcessor extends InsecureChannelProcessor {
	@Override
	public void decide(FilterInvocation invocation, Collection<ConfigAttribute> config) throws IOException, ServletException {
		if ((invocation == null) || (config == null)) {
			throw new IllegalArgumentException("Nulls cannot be provided");
		}
		String forwardedProto = invocation.getHttpRequest().getHeader("X-Forwarded-Proto");
		for (ConfigAttribute attribute : config) {
			if (supports(attribute)) {
				if (forwardedProto != null) {
					if (forwardedProto.equals("https")) {
						getEntryPoint().commence(invocation.getRequest(), invocation.getResponse());
					}
				} else {
					if (invocation.getHttpRequest().isSecure()) {
						getEntryPoint().commence(invocation.getRequest(), invocation.getResponse());
					}
				}
			}
		}
	}
}

InsecureChannelProcessor のサブクラスの実装例

public class ProxySecureChannelProcessor extends SecureChannelProcessor {
	@Override
	public void decide(FilterInvocation invocation, Collection<ConfigAttribute> config) throws IOException, ServletException {
		Assert.isTrue((invocation != null) && (config != null), "Nulls cannot be provided");
		String forwardedProto = invocation.getHttpRequest().getHeader("X-Forwarded-Proto");
		for (ConfigAttribute attribute : config) {
			if (supports(attribute)) {
				if (forwardedProto != null) {
					if (!forwardedProto.equals("https")) {
						getEntryPoint().commence(invocation.getRequest(), invocation.getResponse());
					}
				} else {
					if (!invocation.getHttpRequest().isSecure()) {
						getEntryPoint().commence(invocation.getRequest(), invocation.getResponse());
					}
				}
			}
		}
	}
}

上記2つのクラスを使用するように、Spring Security の設定を下記のように記述します。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		List<ChannelProcessor> channelProcessors = new ArrayList<>();
		channelProcessors.add(new ProxySecureChannelProcessor());
		channelProcessors.add(new ProxyInsecureChannelProcessor());
		http
			.authorizeRequests()
				.antMatchers("/**").hasRole("USER")
				.and()
			.formLogin()
				.and()
			.requiresChannel()
				.channelProcessors(channelProcessors)
				.anyRequest().requiresSecure();
		...


▼検証環境
Spring Security 3.2.4.RELEASE