アーキテクチャー

このセクションでは、サーブレットベースのアプリケーション内の Spring Security の高レベルアーキテクチャについて説明します。リファレンスの認証認可エクスプロイトに対する保護セクション内のこの高レベルの理解に基づいています。

フィルターのレビュー

Spring Security のサーブレットサポートはサーブレットフィルターに基づいているため、一般的に最初にフィルターのロールを確認すると便利です。次のイメージは、単一の HTTP リクエストのハンドラーの一般的な階層化を示しています。

filterchain
図 1: FilterChain

クライアントはアプリケーションにリクエストを送信し、コンテナーは FilterChain を作成します。これには、リクエスト URI のパスに基づいて、Filter インスタンスと HttpServletRequest を処理する必要がある Servlet が含まれます。Spring MVC アプリケーションでは、Servlet は DispatcherServlet のインスタンスです。最大で、1 つの Servlet が 1 つの HttpServletRequest および HttpServletResponse を処理できます。ただし、複数の Filter を使用して次のことができます。

  • ダウンストリーム Filter インスタンスまたは Servlet が呼び出されないようにします。この場合、Filter は通常 HttpServletResponse を書き込みます。

  • ダウンストリーム Filter インスタンスおよび Servlet によって使用される HttpServletRequest または HttpServletResponse を変更します。

Filter のパワーは、それに渡される FilterChain から得られます。

FilterChain の使用例
  • Java

  • Kotlin

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	// do something before the rest of the application
    chain.doFilter(request, response); // invoke the rest of the application
    // do something after the rest of the application
}
fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
    // do something before the rest of the application
    chain.doFilter(request, response) // invoke the rest of the application
    // do something after the rest of the application
}

Filter はダウンストリームの Filter インスタンスと Servlet にのみ影響を与えるため、各 Filter が呼び出される順序は非常に重要です。

DelegatingFilterProxy

Spring は、DelegatingFilterProxy (Javadoc) という名前の Filter 実装を提供します。これにより、サーブレットコンテナーのライフサイクルと Spring の ApplicationContext の間のブリッジが可能になります。サーブレットコンテナーでは、独自の標準を使用して Filter インスタンスを登録できますが、Spring で定義された Bean を認識していません。標準のサーブレットコンテナーメカニズムを介して DelegatingFilterProxy を登録できますが、すべての作業を Filter を実装する Spring Bean に委譲します。

DelegatingFilterProxy が Filter インスタンスと FilterChain にどのように適合するかの図です。

delegatingfilterproxy
図 2: DelegatingFilterProxy

DelegatingFilterProxy は、ApplicationContext から Bean フィルター 0 を検索してから、Bean フィルター 0 を呼び出します。次のリストは、DelegatingFilterProxy の擬似コードを示しています。

DelegatingFilterProxy 擬似コード
  • Java

  • Kotlin

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	Filter delegate = getFilterBean(someBeanName); (1)
	delegate.doFilter(request, response); (2)
}
fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
	val delegate: Filter = getFilterBean(someBeanName) (1)
	delegate.doFilter(request, response) (2)
}
1Spring Bean として登録された Filter を遅延取得します。DelegatingFilterProxy の例では、delegate は Bean Filter 0 のインスタンスです。
2 作業を Spring Bean に委譲します。

DelegatingFilterProxy のもう 1 つの利点は、Filter Bean インスタンスの検索を遅らせることができることです。コンテナーを起動する前に、コンテナーが Filter インスタンスを登録する必要があるため、これは重要です。ただし、Spring は通常 ContextLoaderListener を使用して Spring Bean をロードします。これは、Filter インスタンスを登録する必要があるまで実行されません。

FilterChainProxy

Spring Security のサーブレットサポートは FilterChainProxy に含まれています。FilterChainProxy は、SecurityFilterChain を介して多くの Filter インスタンスに委譲できる、Spring Security によって提供される特別な Filter です。FilterChainProxy は Bean であるため、通常は DelegatingFilterProxy にラップされます。

次のイメージは、FilterChainProxy のロールを示しています。

filterchainproxy
図 3: FilterChainProxy

SecurityFilterChain

SecurityFilterChain (Javadoc) は、FilterChainProxy によって使用され、現在のリクエストに対してどの Spring Security Filter インスタンスを呼び出す必要があるかを判別します。

次のイメージは、SecurityFilterChain のロールを示しています。

securityfilterchain
図 4: SecurityFilterChain

SecurityFilterChain のセキュリティフィルターは通常 Bean ですが、DelegatingFilterProxy ではなく FilterChainProxy に登録されます。FilterChainProxy には、サーブレットコンテナーまたは DelegatingFilterProxy に直接登録することで多くの利点があります。まず、Spring Security のすべてのサーブレットサポートの開始点を提供します。そのため、Spring Security のサーブレットサポートのトラブルシューティングを行う場合は、FilterChainProxy にデバッグポイントを追加することから始めるのが最適です。

第 2 に、FilterChainProxy は Spring Security の使用箇所の中心であるため、オプションとは見なされないタスクを実行できます。例: メモリリークを回避するために SecurityContext をクリアします。また、Spring Security の HttpFirewall を適用して、特定の種類の攻撃からアプリケーションを保護します。

さらに、SecurityFilterChain をいつ呼び出すかをより柔軟に決定できます。サーブレットコンテナーでは、Filter インスタンスは URL のみに基づいて呼び出されます。ただし、FilterChainProxy は、RequestMatcher インターフェースを使用して、HttpServletRequest 内のすべてに基づいて呼び出しを判別できます。

次のイメージは、複数の SecurityFilterChain インスタンスを示しています。

multi securityfilterchain
図 5: 複数の SecurityFilterChain

複数の SecurityFilterChain の図では、FilterChainProxy がどの SecurityFilterChain を使用するかを決定します。一致する最初の SecurityFilterChain のみが呼び出されます。/api/messages/ の URL がリクエストされた場合、最初に /api/** の SecurityFilterChain0 パターンと一致するため、SecurityFilterChainn でも一致する場合でも、SecurityFilterChain0 のみが呼び出されます。/messages/ の URL がリクエストされた場合、/api/** の SecurityFilterChain0 パターンと一致しないため、FilterChainProxy は各 SecurityFilterChain の試行を続行します。他の SecurityFilterChain インスタンスが一致しないと仮定すると、SecurityFilterChainn が呼び出されます。

SecurityFilterChain0 には 3 つのセキュリティ Filter インスタンスしか構成されていないことに注意してください。ただし、SecurityFilterChainn には 4 つのセキュリティ Filter インスタンスが構成されています。各 SecurityFilterChain は一意であり、分離して構成できることに注意することが重要です。実際、アプリケーションが Spring Security に特定のリクエストを無視させたい場合、SecurityFilterChain はセキュリティ Filter インスタンスを持たない可能性があります。

セキュリティフィルター

セキュリティフィルターは、SecurityFilterChain API を使用して FilterChainProxy に挿入されます。これらのフィルターは、認証認可エクスプロイト保護など、さまざまな目的に使用できます。フィルターは、適切なタイミングで呼び出されることを保証するために、特定の順序で実行されます。たとえば、認証を実行する Filter は、認可を実行する Filter より前に呼び出す必要があります。通常、Spring Security の Filter の順序を知る必要はありません。ただし、順序を知っておくと有益な場合もあります。順序を知りたい場合は、FilterOrderRegistration コード [GitHub] (英語) を確認してください。

上の段落を例として、次のセキュリティ構成を考えてみましょう。

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

}
import org.springframework.security.config.web.servlet.invoke

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { }
            authorizeHttpRequests {
                authorize(anyRequest, authenticated)
            }
            httpBasic { }
            formLogin { }
        }
        return http.build()
    }

}

上記の構成により、Filter の順序は次のようになります。

フィルター 追加

CsrfFilter

HttpSecurity#csrf

UsernamePasswordAuthenticationFilter

HttpSecurity#formLogin

BasicAuthenticationFilter

HttpSecurity#httpBasic

AuthorizationFilter

HttpSecurity#authorizeHttpRequests

  1. まず、CSRF 攻撃から保護するために CsrfFilter が呼び出されます。

  2. 次に、リクエストを認証するために認証フィルターが呼び出されます。

  3. 3 番目に、AuthorizationFilter が呼び出されて、リクエストが承認されます。

上記にリストされていない他の Filter インスタンスが存在する可能性があります。特定のリクエストに対して呼び出されたフィルターのリストを表示したい場合は、出力できます。

セキュリティフィルターを出力する

多くの場合、特定のリクエストに対して呼び出されるセキュリティ Filter のリストを確認すると便利です。例: 追加したフィルターがセキュリティフィルターのリストに含まれていることを確認したい場合。

フィルターのリストはアプリケーションの起動時に INFO レベルで出力されるため、たとえばコンソール出力に次のような内容が表示されます。

2023-06-14T08:55:22.321-03:00  INFO 76975 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]

これにより、各フィルターチェーンに構成されているセキュリティフィルターについてかなりよく理解できるようになります。

しかし、それだけではありません。リクエストごとに個別のフィルターの呼び出しを出力するようにアプリケーションを構成することもできます。これは、追加したフィルターが特定のリクエストに対して呼び出されているかどうかを確認したり、例外の発生元を確認したりできます。これを行うには、セキュリティイベントをログに記録するようにアプリケーションを構成します。

フィルターチェーン にカスタムフィルターを追加する

ほとんどの場合、アプリケーションにセキュリティを提供するには、デフォルトのセキュリティフィルターで十分です。ただし、カスタム Filter をセキュリティフィルターチェーン に追加したい場合があります。

例: テナント ID ヘッダーを取得する Filter を追加し、現在のユーザーがそのテナントにアクセスできるかどうかを確認するとします。前の説明では、現在のユーザーを知る必要があるため、認証フィルターの後にフィルターを追加する必要があるため、フィルターを追加する場所についての手がかりがすでに得られています。

まず、Filter を作成しましょう。

import java.io.IOException;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.access.AccessDeniedException;

public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String tenantId = request.getHeader("X-Tenant-Id"); (1)
        boolean hasAccess = isUserAllowed(tenantId); (2)
        if (hasAccess) {
            filterChain.doFilter(request, response); (3)
            return;
        }
        throw new AccessDeniedException("Access denied"); (4)
    }

}

上記のサンプルコードは次のことを行います。

1 リクエストヘッダーからテナント ID を取得します。
2 現在のユーザーがテナント ID にアクセスできるかどうかを確認します。
3 ユーザーがアクセス権を持っている場合は、チェーン 内の残りのフィルターを呼び出します。
4 ユーザーにアクセス権がない場合は、AccessDeniedException をスローします。

Filter を実装する代わりに、リクエストごとに 1 回だけ呼び出され、HttpServletRequest および HttpServletResponse パラメーターを備えた doFilterInternal メソッドを提供するフィルターの基本クラスである OncePerRequestFilter (Javadoc) から拡張できます。

ここで、フィルターをセキュリティフィルターチェーン に追加する必要があります。

  • Java

  • Kotlin

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class); (1)
    return http.build();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http
        // ...
        .addFilterBefore(TenantFilter(), AuthorizationFilter::class.java) (1)
    return http.build()
}
1HttpSecurity#addFilterBefore を使用して、AuthorizationFilter の前に TenantFilter を追加します。

AuthorizationFilter の前にフィルターを追加することで、認証フィルターの後に TenantFilter が確実に呼び出されるようになります。HttpSecurity#addFilterAfter を使用して特定のフィルターの後にフィルターを追加したり、HttpSecurity#addFilterAt を使用してフィルターチェーン 内の特定のフィルター位置にフィルターを追加したりすることもできます。

これで、TenantFilter がフィルターチェーン で呼び出され、現在のユーザーがテナント ID にアクセスできるかどうかを確認します。

フィルターを @Component でアノテーションを付けるか、構成内で Bean として宣言することによって、フィルターを Spring Bean として宣言する場合は注意してください。Spring Boot がフィルターを組み込みコンテナーに自動的に登録するためです。これにより、フィルターが 2 回呼び出され、1 回はコンテナーによって、もう 1 回は Spring Security によって、異なる順序で呼び出される可能性があります。

依存性注入などを利用して重複呼び出しを回避するためにフィルターを Spring Bean として宣言したい場合は、FilterRegistrationBean Bean を宣言し、その enabled プロパティを false に設定することで、フィルターをコンテナーに登録しないように Spring Boot に指示できます。:

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

セキュリティ例外の処理

ExceptionTranslationFilter は、セキュリティフィルターの 1 つとして FilterChainProxy に挿入されます。

次のイメージは、ExceptionTranslationFilter と他のコンポーネントとの関連を示しています。

exceptiontranslationfilter
  • number 1 まず、ExceptionTranslationFilter が FilterChain.doFilter(request, response) を呼び出して、残りのアプリケーションを呼び出します。

  • number 2 ユーザーが認証されていない場合、または AuthenticationException の場合は、認証を開始します。

    • SecurityContextHolder はクリアされます。

    • HttpServletRequest は、認証が成功した後に元のリクエストを再生するために使用できるように保存されます。

    • AuthenticationEntryPoint は、クライアントから資格情報をリクエストするために使用されます。例: ログインページにリダイレクトするか、WWW-Authenticate ヘッダーを送信する場合があります。

  • number 3 それ以外の場合、それが AccessDeniedException の場合、アクセスが拒否されましたAccessDeniedHandler は、拒否されたアクセスを処理するために呼び出されます。

アプリケーションが AccessDeniedException または AuthenticationException をスローしない場合、ExceptionTranslationFilter は何もしません。

ExceptionTranslationFilter の擬似コードは次のようになります。

ExceptionTranslationFilter 擬似コード
try {
	filterChain.doFilter(request, response); (1)
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		startAuthentication(); (2)
	} else {
		accessDenied(); (3)
	}
}
1 フィルターのレビューに従って、FilterChain.doFilter(request, response) を呼び出すことは、アプリケーションの残りの部分を呼び出すことと同じです。これは、アプリケーションの別の部分(FilterSecurityInterceptor またはメソッドセキュリティ)が AuthenticationException または AccessDeniedException をスローした場合、ここでキャッチされて処理されることを意味します。
2 ユーザーが認証されていないか、AuthenticationException の場合は、認証を開始します。
3 それ以外の場合、アクセスは拒否されました

認証間のリクエストの保存

セキュリティ例外の処理に示されているように、リクエストに認証がなく、認証が必要なリソースに対するものである場合、認証が成功した後に再リクエストするために、認証されたリソースのリクエストを保存する必要があります。Spring Security では、これは RequestCache 実装を使用して HttpServletRequest を保存することによって行われます。

RequestCache

HttpServletRequest は RequestCache (Javadoc) に保存されます。ユーザーが正常に認証されると、RequestCache を使用して元のリクエストが再生されます。RequestCacheAwareFilter は、ユーザーが認証した後、RequestCache を使用して保存された HttpServletRequest を取得します。一方、ExceptionTranslationFilter は、AuthenticationException を検出した後、ユーザーをログインエンドポイントにリダイレクトする前に、RequestCache を使用して HttpServletRequest を保存します。

デフォルトでは、HttpSessionRequestCache が使用されます。以下のコードは、continue という名前のパラメーターが存在する場合に、保存されたリクエストの HttpSession をチェックするために使用される RequestCache 実装をカスタマイズする方法を示しています。

 continue パラメーターが存在する場合、RequestCache は保存されたリクエストのみをチェックします
  • Java

  • Kotlin

  • XML

@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
	HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
	requestCache.setMatchingRequestParameterName("continue");
	http
		// ...
		.requestCache((cache) -> cache
			.requestCache(requestCache)
		);
	return http.build();
}
@Bean
open fun springSecurity(http: HttpSecurity): SecurityFilterChain {
    val httpRequestCache = HttpSessionRequestCache()
    httpRequestCache.setMatchingRequestParameterName("continue")
    http {
        requestCache {
            requestCache = httpRequestCache
        }
    }
    return http.build()
}
<http auto-config="true">
	<!-- ... -->
	<request-cache ref="requestCache"/>
</http>

<b:bean id="requestCache" class="org.springframework.security.web.savedrequest.HttpSessionRequestCache"
	p:matchingRequestParameterName="continue"/>

リクエストが保存されないようにする

ユーザーの認証されていないリクエストをセッションに保存したくない理由はいくつかあります。そのストレージをユーザーのブラウザーにオフロードするか、データベースに保存することができます。または、ユーザーがログイン前にアクセスしようとしたページではなく、常にホームページにリダイレクトする必要があるため、この機能をオフにすることもできます。

これを行うには、NullRequestCache 実装 (Javadoc) を使用できます。

リクエストが保存されないようにする
  • Java

  • Kotlin

  • XML

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}
@Bean
open fun springSecurity(http: HttpSecurity): SecurityFilterChain {
    val nullRequestCache = NullRequestCache()
    http {
        requestCache {
            requestCache = nullRequestCache
        }
    }
    return http.build()
}
<http auto-config="true">
	<!-- ... -->
	<request-cache ref="nullRequestCache"/>
</http>

<b:bean id="nullRequestCache" class="org.springframework.security.web.savedrequest.NullRequestCache"/>

RequestCacheAwareFilter

RequestCacheAwareFilter (Javadoc) RequestCache を使用して元のリクエストを再生します。

ログ

Spring Security は、DEBUG および TRACE レベルですべてのセキュリティ関連イベントの包括的なログを提供します。セキュリティ対策のため、Spring Security はリクエストが拒否された理由の詳細をレスポンス本文に追加しないため、これはアプリケーションをデバッグするときに非常に役立ちます。401 または 403 エラーが発生した場合は、何が起こっているかを理解するのに役立つログメッセージが見つかる可能性が高くなります。

ユーザーが CSRF トークンを使用せずに、CSRF の保護が有効になっているリソースに対して POST リクエストを実行しようとする例を考えてみましょう。ログがないと、ユーザーにはリクエストが拒否された理由の説明のない 403 エラーが表示されます。ただし、Spring Security のログを有効にすると、次のようなログメッセージが表示されます。

2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Securing POST /hello
2023-06-14T09:44:25.797-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/15)
2023-06-14T09:44:25.798-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/15)
2023-06-14T09:44:25.800-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/15)
2023-06-14T09:44:25.801-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/15)
2023-06-14T09:44:25.802-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/15)
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for http://localhost:8080/hello
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2023-06-14T09:44:25.814-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]

CSRF トークンが見つからないため、リクエストが拒否されていることが明らかになります。

すべてのセキュリティイベントをログに記録するようにアプリケーションを構成するには、アプリケーションに以下を追加します。

application.properties in Spring Boot
logging.level.org.springframework.security=TRACE
logback.xml
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <!-- ... -->
    </appender>
    <!-- ... -->
    <logger name="org.springframework.security" level="trace" additivity="false">
        <appender-ref ref="Console" />
    </logger>
</configuration>