Spring WebFlux で Spring Security の認証と認可を使う

概要

Spring Security を使う場合、Spring WebFlux と Spring MVC では仕組みが異なっています。このため、Spring MVC と併せて使う場合のカスタマイズ方法と Spring WebFlux と併せて使う場合のカスタマイズ方法も異なります。Spring Security を Spring WebFlux と併せて使う場合のカスタマイズ方法を少しだけ調べました。なお、コードは Kotlin で書かれています。

目次

確認環境

  • Spring Boot 2.0.3
  • Kotlin 1.2.50

参考情報

解説

仕組み

まずは Spring Security 5.0 解剖速報 の内容を見るのが良いと思います。Form-based Authentication (認証) に関わるクラスとインターフェースが整理されています。また、設定の方法も記載されており、それを参考にして書いた設定例が以下です。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

    /**
     * インメモリでユーザー情報を保持する場合の設定
     */
    @Bean
    fun userDetailsService(passwordEncoder: PasswordEncoder): ReactiveUserDetailsService {
        return MapReactiveUserDetailsService(
                User.withUsername("user")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("password")
                        .roles("USER")
                        .build(),
                User.withUsername("admin")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("admin")
                        .roles("USER", "ADMIN")
                        .build()
        )
    }

    @Bean
    fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http.authorizeExchange()
                .pathMatchers("/login").permitAll()
                .pathMatchers("/admin").hasRole("ADMIN")
                .anyExchange().authenticated()
        http.formLogin()
                .loginPage("/login")
        return http.build()
    }

withDefaultPasswordEncoder メソッドは非推奨になっているため、withUsername に変えています。withDefaultPasswordEncoder メソッドの実装を見ると PasswordEncoderFactories.createDelegatingPasswordEncoder() によってエンコーダーを取得しているので、同様にしています。

springSecurityFilterChain では WebFilter の登録を行っています。ServerHttpSecurity クラスの build メソッドを実行すると、AuthorizeExchangeSpec, FormLoginSpec, LogoutSpec, CsrfSpec などのクラスの configure メソッドが実行され、各 configure メソッドでは ServerHttpSecurity クラスの addFilterAt メソッドを実行して WebFilter を登録します。これらは、ServerHttpSecurity クラスのコード で確認できます。

例えば、authorizeExchange メソッドを実行すると、AuthorizeExchangeSpec オブジェクトが生成されます。AuthorizeExchangeSpec クラスの pathMatchers, anyExchange メソッドで設定を変更し、ServerHttpSecurity クラスの build メソッドを実行すると AuthorizeExchangeSpec クラスの configure メソッドが実行され、設定に応じた AuthorizationWebFilter が登録されます。

登録される WebFilter を整理すると以下の通りになっています。(一部です。詳しくは ServerHttpSecurity クラスのコード参照。)

No. 登録される WebFilter WebFilter を登録する Spec クラス Spec オブジェクトを生成するタイミング
1 SecurityContextServerWebExchange WebFilter - -
2 ExceptionTranslationWebFilter - -
3 AuthorizationWebFilter AuthorizeExchangeSpec authorizeExchange メソッド実行時
4 CsrfWebFilter CsrfSpec ServerHttpSecurity 初期化時
5 ServerRequestCacheWebFilter RequestCacheSpec ServerHttpSecurity 初期化時
6 AuthenticationWebFilter HttpBasicSpec httpBasic メソッド実行時
7 AuthenticationWebFilter FormLoginSpec formLogin メソッド実行時
8 HttpHeaderWriterWebFilter HeaderSpec ServerHttpSecurity 初期化時
9 LogoutWebFilter LogoutSpec logout メソッド実行時

(1) SecurityContextServerWebExchangeWebFilter は ServerHttpSecurity クラスの build メソッド実行時に登録されます。(2) ExceptionTranslationWebFilter は AuthorizeExchangeSpec オブジェクトが生成されている場合のみ登録されます。(3-9) これら以外の WebFilter は、各 Spec クラスの configure メソッドで登録されます。

上で示したとおり、各 Spec クラスのオブジェクトは、ServerHttpSecurity が初期化される時に生成される場合とメソッドを呼び出した時に生成される場合に分かれます。各 Spec オブジェクトは、各 Spec の disable メソッドで無効にできます。無効にした場合には、基本的には WebFilter が登録されません。但し、ServerRequestCacheWebFilter だけは例外で、無効にしても WebFilter 自体は登録されます。(機能としては無効になります。)AuthorizationWebFilter, AuthenticationWebFilter, LogoutWebFilter はデフォルトで無効であり、WebFilter は登録されません。CsrfWebFilter, ServerRequestCacheWebFilter, HttpHeaderWriterWebFilter はデフォルトで有効となるため、無効にしたい場合には以下のとおりにします。

http.csrf().disable()
http.headers().disable()
http.requestCache().disable()

各 Spec を使わずに、ServerHttpSecurity に直接 WebFilter を登録することもできます。

http.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)

2 つめの引数は SecurityWebFiltersOrder、つまり WebFilter の適用順です。指定できる値としては以下が用意されています。各 WebFilter との対応も併せて示します。(WebFilter は一部のみ。)

SecurityWebFiltersOrder WebFilter
FIRST
HTTP_HEADERS_WRITER HttpHeaderWriterWebFilter
CSRF CsrfWebFilter
REACTOR_CONTEXT
HTTP_BASIC AuthenticationWebFilter
FORM_LOGIN AuthenticationWebFilter
AUTHENTICATION
LOGIN_PAGE_GENERATING
LOGOUT_PAGE_GENERATING
SECURITY_CONTEXT_SERVER_WEB_EXCHANGE SecurityContextServerWebExchange WebFilter
SERVER_REQUEST_CACHE ServerRequestCacheWebFilter
LOGOUT LogoutWebFilter
EXCEPTION_TRANSLATION ExceptionTranslationWebFilter
AUTHORIZATION AuthorizationWebFilter
LAST

カスタマイズ

様々な WebFilter を ServerHttpSecurity に登録することで認証や認可が行われています。カスタマイズするためには、これらの WebFilter の登録を有効/無効にしたり、WebFilter の挙動を変更すればよさそうです。実際に、カスタマイズを行っているサンプルでは、そのようにしています。

WebFilter の登録を無効にするには、既に述べたとおりに disable メソッドを使うか、Spec オブジェクトを生成するメソッドを実行しないようにします。有効にするには、その反対を行います。WebFilter の挙動を変更する方法としては、Spec の設定を変える方法と WebFilter を新規作成して登録する方法が考えられます。

Spec の設定を変える

例として、FormLoginSpec の設定を変更します。

@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    http.formLogin()
            .loginPage("/login")
            .requiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"))
            .authenticationEntryPoint(EntryPoint())
            .authenticationFailureHandler(FailureHandler())
            .authenticationSuccessHandler(SuccessHandler())
    return http.build()
}

formLogin メソッドは、FormLoginSpec オブジェクトを生成します。そして、生成されたオブジェクトの requiresAuthenticationMatcher, authenticationEntryPoint などのメソッドを使えば設定を変更できます。FormLoginSpec での基本的な設定項目は以下の 6 つです。デフォルトは、formLogin メソッドだけを実行した場合の設定です。

  • requiresAuthenticationMatcher
    • この Matcher でマッチした場合に認証処理を行う
    • デフォルトでは、/login に POST メソッドでリクエストした場合に認証処理を行う
  • authenticationEntryPoint
    • 認可で拒否された場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationEntryPoint("/login") であり、/login にリダイレクトする
    • AuthorizeExchangeSpec オブジェクトが生成されている場合、ここで設定した処理が ExceptionTranslationWebFilter に設定される
  • authenticationFailureHandler
    • 認証に失敗した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationFailureHandler("/login?error") であり、/login にリダイレクトする
  • authenticationSuccessHandler
    • 認証に成功した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationSuccessHandler("/") であり、/ にリダイレクトする
  • authenticationManager
  • securityContextRepository

loginPage メソッドは、これらの項目の一部をまとめて設定します。変数 loginPage は loginPage メソッドの引数です。

  • requiresAuthenticationMatcher
    • ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPage)
  • authenticationEntryPoint
    • RedirectServerAuthenticationEntryPoint(loginPage)
  • authenticationFailureHandler
    • RedirectServerAuthenticationFailureHandler(loginPage + "?error")

authenticationEntryPoint が設定されていない場合には、変数 loginPage の値を "/login" として loginPage メソッドが実行されます。そのため、デフォルトが "/login" になっています。

AuthenticationWebFilter は、これら 6 項目の設定に基づいて生成されます。しかし、AuthenticationWebFilter のコードを確認すると、上記とは一部が異なる以下の 6 項目を設定します。

No. 設定項目 設定方法
1 AuthenticationManager ServerHttpSecurity
コンストラク
2 SecurityContextRepository ServerHttpSecurity ::
securityContextRepository
3 RequiresAuthenticationMatcher FormLoginSpec ::
requiresAuthenticationMatcher
4 ServerAuthenticationFailureHandler FormLoginSpec ::
authenticationFailureHandler
5 ServerAuthenticationSuccessHandler FormLoginSpec ::
authenticationSuccessHandler
6 AuthenticationConverter 設定不可

authenticationEntryPoint がなく、代わりに AuthenticationConverter があります。authenticationEntryPoint は ExceptionTranslationWebFilter に設定されるため、AuthenticationWebFilter の設定項目には含まれません。

(3-5) RequiresAuthenticationMatcher, ServerAuthenticationFailureHandler, ServerAuthenticationSuccessHandler は formLogin メソッドを使って設定した内容が適用されます。(1-2) また、AuthenticationManager, SecurityContextRepository は ServerHttpSecurity のコンストラクタやメソッドで設定可能です。(6) しかし、AuthenticationConverter は設定を変更できません。

AuthenticationConverter は認証に使用する username と password を抽出する処理を持つオブジェクトを設定します。formLogin の場合には、ServerFormLoginAuthenticationConverter オブジェクトが設定されており、FormData から username と password を抽出する処理になっています。AuthenticationConverter の設定を変えられないため、例えば、request body に JSON 形式で username と password を指定するといった事ができません。この例の方法を使いたい場合には、WebFilter を新規作成する必要があります。これは後ほど説明します。

WebFilter を新規作成して登録する

Spec の設定を変える方法では、AuthenticationConverter を変更できなかったり、各設定項目が複雑に絡み合ってカスタマイズが困難になるといった問題があります。この問題を解決するための一つの方法としては、自作 WebFilter を登録する方法があります。例えば、以下のとおりです。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {
    // ...

    private val loginPath = "/login"

    @Bean
    fun securityFilterChain(
            http: ServerHttpSecurity,
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer
    ): SecurityWebFilterChain {
        // ...

        // 認証(authentication)の設定
        val authenticationFilter = authenticationWebFilter(
                authenticationManager,
                serverCodecConfigurer,
                ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPath)
        )
        http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

        // 認可(authorization)で拒否した場合の応答
        http.exceptionHandling()
                .authenticationEntryPoint(EntryPoint())

        return http.build()
    }

    // @Bean は付けない (WebFilter が 2 重で登録されてしまう)
    fun authenticationWebFilter(
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer,
            loginPath: ServerWebExchangeMatcher
    ): WebFilter {
        return AuthenticationWebFilter(authenticationManager).apply {
            // 認証処理を行うリクエスト
            setRequiresAuthenticationMatcher(loginPath)
            // 認証処理における認証情報を抽出方法
            setAuthenticationConverter(JsonBodyAuthenticationConverter(serverCodecConfigurer.readers))
            // 認証成功/失敗時の処理
            setAuthenticationSuccessHandler(SuccessHandler())
            setAuthenticationFailureHandler(FailureHandler())
            // セキュリティコンテキストの保存方法
            setSecurityContextRepository(WebSessionServerSecurityContextRepository())
        }
    }

    // authenticationManager をカスタマイズしない場合には不要
    @Bean
    fun authenticationManager(
            userDetailsService: UserDetailsService,
            passwordEncoder: PasswordEncoder
    ): ReactiveAuthenticationManager {
        return UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService).apply {
            setPasswordEncoder(passwordEncoder) // 設定しない場合は DelegatingPasswordEncoder になる
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class EntryPoint : ServerAuthenticationEntryPoint {
        override fun commence(exchange: ServerWebExchange, e: AuthenticationException): Mono<Void> {
            return Mono.fromRunnable {
                exchange.response.statusCode = HttpStatus.FORBIDDEN
            }
        }
    }

    // ステータスコード OK、本文は空でレスポンスを返す
    inner class SuccessHandler : ServerAuthenticationSuccessHandler {
        override fun onAuthenticationSuccess(
                webFilterExchange: WebFilterExchange,
                authentication: Authentication
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.OK
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class FailureHandler : ServerAuthenticationFailureHandler {
        override fun onAuthenticationFailure(
                webFilterExchange: WebFilterExchange,
                exception: AuthenticationException
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.FORBIDDEN
        }
    }

    // 想定するリクエストの本文は { "mail_address": "user@example.com", "password": "123456" } といった JSON
    inner class JsonBodyAuthenticationConverter(
            val messageReaders: List<HttpMessageReader<*>>
    ) : Function<ServerWebExchange, Mono<Authentication>> {

        override fun apply(exchange: ServerWebExchange): Mono<Authentication> {
            return BodyExtractors.toMono(AuthenticationInfo::class.java)
                    .extract(exchange.request, object : BodyExtractor.Context {
                        override fun messageReaders(): List<HttpMessageReader<*>> = messageReaders
                        override fun serverResponse(): Optional<ServerHttpResponse> = Optional.of(exchange.response)
                        override fun hints(): Map<String, Any> = mapOf()
                    })
                    .map { it.toToken() }
        }
    }

    // 認証リクエスト本文の JSON
    data class AuthenticationInfo(
            @JsonProperty("mail_address")
            val mailAddress: String,
            val password: String
    ) {
        fun toToken() = UsernamePasswordAuthenticationToken(mailAddress, password)
    }

最も重要な部分は

http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

です。この 1 文を実行するために、様々な処理を行っています。登録している AuthenticationWebFilter は "/login"

{ "mail_address": "user@example.com", "password": "123456" }

の様な JSON を POST メソッドで送信して認証します。認証に成功した場合はステータスコード OK を返し、失敗した場合は FORBIDDEN を返します。認可に失敗した場合は FORBIDDEN が返されます。

登録する AuthenticationWebFilter オブジェクトは authenticationManager メソッドで生成しています。authenticationManager メソッドに @Bean アノテーションを付けると SecurityFilterChain 中に 1 つ、後に 1 つ登録され、2 重登録されてしまいます。

authenticationManager メソッドは無くても構いませんが、カスタマイズする場合の例として記述してあります。passwordEncoder を Bean として設定している場合には注意が必要です。UserDetailsRepositoryReactiveAuthenticationManager に passwordEncoder を設定しておく必要があります。設定しない場合には、DelegatingPasswordEncoder が使われます。

また、AuthenticationWebFilter だけではなく、authenticationEntryPoint の設定をしておきます。authenticationEntryPoint には、認可に失敗した場合のレスポンスを設定しています。formLogin メソッドを使う場合には、AuthenticationWebFilter の登録と併せて行われていました。