環境

  • Kotlin 1.6
  • Spring Boot 2.6.0
  • Spring Security 5.6.0
  • Redis 6

Gradleの設定含め詳細はGitHubにて記載

関連

Spring SecurityでREST API + JSONによる認証を行う(JWT編)

Spring BootからRedisを使うときはGenericJackson2JsonRedisSerializerでJSONとオブジェクトをマッピングする ※関連の理由: SessionをJSONでRedisに保存する際にGenericJackson2JsonRedisSerializerを利用するため

Spring SecurityでREST APIを使って認証する

Spring Securityの認証方法のうちデフォルトで用意されていて一般的によく使われるのがformLogin()となる。しかし現在はバックエンドとフロントエンドが別れていることが多く、REST APIで通信させるため、認証部分もForm認証ではなくREST APIで実装したい。

Spring Securityの設定

各APIの認証要否の設定

Form認証 vs REST APIとは関係ないが書いておきたい点について事前に記載する。

各APIが認証を要するかどうかは@PreAuthorizeを用いてControllerで設定する。

理由は、WebSecurityConfigmvcMatchersを使うよりも柔軟性が高く、またAPIのURLと認証要件が同じ場所に書かれる方がわかりやすいから。

SessionとRedisとGenericJackson2JsonRedisSerializer

こちらもForm認証 vs REST APIとは直接関係ないが書いておきたい点について事前に記載する。

詳しくはSpring BootからRedisを使うときはGenericJackson2JsonRedisSerializerでJSONとオブジェクトをマッピングするに記載しているが、SessionをRedisに保存する際にGenericJackson2JsonRedisSerializerの設定をしたく、Session用の設定を行う。

@Configuration
class SessionConfig {
    @Bean
    fun springSessionDefaultRedisSerializer(): RedisSerializer<Any> {
        return GenericJackson2JsonRedisSerializer(redisCacheObjectMapper())
    }

    private fun redisCacheObjectMapper(): ObjectMapper {
        val objectMapper = ObjectMapper()
        objectMapper
            .registerModule(JavaTimeModule())
            .registerModule(ParameterNamesModule(JsonCreator.Mode.PROPERTIES))
            .registerModules(SecurityJackson2Modules.getModules(this.javaClass.classLoader))
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .activateDefaultTyping(
                objectMapper.polymorphicTypeValidator,
                DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY
            )
        GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null)
        return objectMapper
    }
}

WebSecurityConfig

早速Spring Securityの設定の要となるWebSecurityConfigurerAdapterを継承したWebSecurityConfigのコードを書いていく。

重要な点はfun configure(http: HttpSecurity)内の以下の3点。


  1. 独自に作成したJsonRequestAuthenticationFilter/api/loginに紐付けている。
  2. session fixation対策: 今回のコードを実装するにあたり他のサイトも見たがsession fixation対策がないものしか見つけられなかった。Spring Securityではsession fixation対策がデフォルトで有効になっていて、Form認証を使っている場合はsession fixation対策がされるはずなので、REST API認証に変えることでセキュリティレベルが落ちないように注意しなければならない。
  3. 200 OKを返す: Form認証では認証成功後にリダイレクトするようになっている。REST APIでは200 OKとしたい。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // for @PreAuthorize, @Secured
class WebSecurityConfig(
    private val jsonRequestAuthenticationProvider: JsonRequestAuthenticationProvider,
    private val objectMapper: ObjectMapper
) : WebSecurityConfigurerAdapter() {

    override fun configure(web: WebSecurity) {
        web.ignoring().antMatchers("/images/**", "/js/**", "/css/**")
    }

    override fun configure(http: HttpSecurity) {
        val jsonAuthFilter = JsonRequestAuthenticationFilter(objectMapper)
        jsonAuthFilter.setRequiresAuthenticationRequestMatcher(AntPathRequestMatcher("/api/login", "POST"))
        jsonAuthFilter.setSessionAuthenticationStrategy(ChangeSessionIdAuthenticationStrategy()) // session fixation対策
        jsonAuthFilter.setAuthenticationSuccessHandler { _, response, _ -> response.status = 200 }
        jsonAuthFilter.setAuthenticationManager(authenticationManagerBean())
        http.addFilter(jsonAuthFilter)

        http.logout()
            .logoutUrl("/api/logout")
            .invalidateHttpSession(true)
            .logoutSuccessHandler { _, response, _ -> response.status = 200 }
    }

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.authenticationProvider(jsonRequestAuthenticationProvider)
    }

    companion object {
        const val IS_AUTHENTICATED_ANONYMOUSLY = "IS_AUTHENTICATED_ANONYMOUSLY"
        const val IS_AUTHENTICATED_REMEMBERED = "IS_AUTHENTICATED_REMEMBERED"
        const val IS_AUTHENTICATED_FULLY = "IS_AUTHENTICATED_FULLY"

        const val ROLE_NORMAL = "ROLE_NORMAL"
        const val ROLE_PREMIUM = "ROLE_PREMIUM"
    }
}

ログイン処理

JsonRequestAuthenticationFilter

独自に作成したJsonRequestAuthenticationFilterは、/api/loginが呼ばれた時に実行されるFilterだ。JsonRequestAuthenticationProviderで実際の認証処理を行うのだが、そのクラスが認証情報を使えるように、JacksonのObjectMapperでリクエストボディのJSONを読み込んでデータ変換を行う。

class JsonRequestAuthenticationFilter(private val objectMapper: ObjectMapper) : UsernamePasswordAuthenticationFilter() {
    override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse?): Authentication {
        val principal = objectMapper.readValue(request.inputStream, EmailAndPasswordJsonRequest::class.java)
        val authRequest = UsernamePasswordAuthenticationToken(principal.email, principal.password)
        setDetails(request, authRequest)
        return authenticationManager.authenticate(authRequest)
    }
}

JsonRequestAuthenticationProvider

JsonRequestAuthenticationProviderは実際の認証処理を行う独自クラス。

DBからemailでユーザーを検索して、パスワードがマッチしているかどうか確認している。

認証できれば、セッションに保存する用に作成したLoginUserというクラスに必要な情報をセットする。

@Configuration
class JsonRequestAuthenticationProvider(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
) : AuthenticationProvider {
    override fun authenticate(authentication: Authentication): Authentication {
        val email = authentication.principal as String
        val password = authentication.credentials as String
        val user = userRepository.findByEmail(email).orElseThrow { BadCredentialsException("no user") }
        if (!passwordEncoder.matches(password, user.password)) {
            throw BadCredentialsException("incorrect password")
        }
        val loginUser = LoginUser(user.id!!, user.roles.map { SimpleGrantedAuthority(it) })
        return UsernamePasswordAuthenticationToken(loginUser, password, loginUser.authorities)
    }

    override fun supports(authentication: Class<*>): Boolean {
        return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
    }
}

LoginUserを包んでいるUsernamePasswordAuthenticationTokenJsonRequestAuthenticationFilterのスーパークラスであるAbstractAuthenticationProcessingFiltersuccessfulAuthenticationメソッドでSecurityContextに設定されるため、最終的にセッションとしてRedisに保存されることとなる。

RestController

@PreAuthorizeを設定していないAPIは認証不要である。そのため、WebSecurityConfigで設定している/api/login以外に、ユーザー登録APIである/api/signup等には認証不要でアクセスできる。

/api/signupではユーザー登録成功時にはログイン済みとみなしたいため、以下のコードを実行している。

  • SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationTokenによるセッション登録
  • httpServletRequest.changeSessionId()によるsession fixation対策

また認証を要しているかどうかに関わらず、@AuthenticationPrincipalを付与したクラスに、Redisからセッションデータを取得してDIしてくれる。

@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

@RestController
class Controller(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
) {
    @PostMapping(path = ["/api/signup"], produces = [MediaType.APPLICATION_JSON_VALUE])
    fun signup(@RequestBody body: EmailAndPasswordJsonRequest, httpServletRequest: HttpServletRequest): String {
        val password = passwordEncoder.encode(body.password)
        val user = userRepository.save(User(email = body.email, password = password))
        // ログイン済とみなす
        val loginUser = LoginUser(user.id!!, user.roles.map { SimpleGrantedAuthority(it) })
        SecurityContextHolder.getContext().authentication =
            UsernamePasswordAuthenticationToken(loginUser, password, loginUser.authorities)
        httpServletRequest.changeSessionId() // session fixation対策
        return """{ "id": ${user.id} }"""
    }

    @GetMapping("/api/non-personal")
    fun nonPersonal(@AuthenticationPrincipal loginUser: LoginUser?): String {
        return if (loginUser == null) {
            "everyone can see. not logged in."
        } else {
            "everyone can see. logged in."
        }
    }

    @Secured(IS_AUTHENTICATED_FULLY) // ログインしていればアクセス可能
    @GetMapping("/api/personal/user")
    fun personalUser(@AuthenticationPrincipal loginUser: LoginUser): User =
        userRepository.findById(loginUser.id)
            .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND) }

    @PreAuthorize("hasRole('$ROLE_NORMAL')") // ログイン時にDBから取得した権限に指定のものが含まれていればアクセス可能
    @GetMapping(path = ["/api/personal/user"], params = ["role"])
    fun personalUserWithRole(@AuthenticationPrincipal loginUser: LoginUser): User =
        userRepository.findById(loginUser.id)
            .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND) }

    /**
     * POST/PUT/DELETEを実行する際に、CSRF TOKEをHTTPヘッダーに X-CSRF-TOKEN: {CSRF TOKEN} のように設定する必要がある。
     * そのため初めてPOST等を実行する前に、このAPIを呼び出してセッションを(なければ)作成しCSRF TOKENを取得する。
     */
    @GetMapping(path = ["/api/csrf-token"], produces = [MediaType.APPLICATION_JSON_VALUE])
    fun csrfToken(csrfToken: CsrfToken): String {
        return """{ "token": "${csrfToken.token}" }"""
    }
}

動作確認

実際にcurlでアクセスして確認する。

CSRFトークン取得

すでに過去にユーザー登録済みであるとして、ログインするためにPOST /api/loginを実行したい。POSTにはCSRF対策がかかっているため、事前にCSRFトークンを取得する必要がある。

$ curl -i -w"\n" localhost:8080/api/csrf-token
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: SESSION=MmM3MzYwMjYtZWFiMS00Y2M3LThmY2ItODgwMzNhZDYwYTNm; Max-Age=3024000; Expires=Sat, 22 Jan 2022 15:21:00 GMT; Path=/; HttpOnly; SameSite=Lax
Content-Type: application/json
Content-Length: 51
Date: Sat, 18 Dec 2021 15:21:00 GMT

{ "token": "72478bbf-e03b-45a9-8f1c-bf168269a5b0" }

セッションIDがMmM3MzYwMjYtZWFiMS00Y2M3LThmY2ItODgwMzNhZDYwYTNmであることと、CSRFトークンが72478bbf-e03b-45a9-8f1c-bf168269a5b0であることがわかった。

ログイン処理

事前に未ログインであることを確認する。

$ curl -w"\n" localhost:8080/api/non-personal \
  --cookie "SESSION=MmM3MzYwMjYtZWFiMS00Y2M3LThmY2ItODgwMzNhZDYwYTNm"

everyone can see. not logged in.


$ curl -w"\n" localhost:8080/api/personal/user \
  --cookie "SESSION=MmM3MzYwMjYtZWFiMS00Y2M3LThmY2ItODgwMzNhZDYwYTNm"

{"timestamp":"2021-12-18T15:08:16.524+00:00","status":403,"error":"Forbidden","path":"/api/personal/user"}

ログインAPIを実行する。

$ curl -i -w"\n" localhost:8080/api/login \
  -d '{"email":"[email protected]", "password":"[email protected]"}' \
  --cookie "SESSION=MmM3MzYwMjYtZWFiMS00Y2M3LThmY2ItODgwMzNhZDYwYTNm" \
  -H "X-CSRF-TOKEN: 72478bbf-e03b-45a9-8f1c-bf168269a5b0" \
  -H "Content-Type: application/json"

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: SESSION=NmRlM2ZiZmItOTNkNC00MzFiLThlZGUtOWI0MTM0ODFlYTUw; Max-Age=3024000; Expires=Sat, 22 Jan 2022 15:23:25 GMT; Path=/; HttpOnly; SameSite=Lax
Content-Length: 0
Date: Sat, 18 Dec 2021 15:23:25 GMT

200 OKが返り、ログインに成功したことと、セッションIDがNmRlM2ZiZmItOTNkNC00MzFiLThlZGUtOWI0MTM0ODFlYTUwに変わったことがわかる。

認証が必要なページにもアクセスできることがわかった。

$ curl -w"\n" localhost:8080/api/non-personal \
  --cookie "SESSION=NmRlM2ZiZmItOTNkNC00MzFiLThlZGUtOWI0MTM0ODFlYTUw"

everyone can see. logged in.


$ curl -w"\n" localhost:8080/api/personal/user \
  --cookie "SESSION=NmRlM2ZiZmItOTNkNC00MzFiLThlZGUtOWI0MTM0ODFlYTUw"

{"id":27,"email":"[email protected]","password":"{bcrypt}$2a$10$agXc47ddJfmu3TNNVuzaVOMuptOmF.ViThEuMJO32kAWx9fvRcosq","roles":["ROLE_NORMAL"]}

ログアウトする。

$ curl -XPOST localhost:8080/api/logout \
  --cookie "SESSION=NmRlM2ZiZmItOTNkNC00MzFiLThlZGUtOWI0MTM0ODFlYTUw" \
  -H "X-CSRF-TOKEN: 72478bbf-e03b-45a9-8f1c-bf168269a5b0"