環境
- 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で設定する。
理由は、WebSecurityConfig
でmvcMatchers
を使うよりも柔軟性が高く、また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点。
- 独自に作成した
JsonRequestAuthenticationFilter
を/api/login
に紐付けている。 - session fixation対策: 今回のコードを実装するにあたり他のサイトも見たがsession fixation対策がないものしか見つけられなかった。Spring Securityではsession fixation対策がデフォルトで有効になっていて、Form認証を使っている場合はsession fixation対策がされるはずなので、REST API認証に変えることでセキュリティレベルが落ちないように注意しなければならない。
- 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対策. これがないとsignup時にhttpServletRequest.changeSessionId()が必要
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
を包んでいるUsernamePasswordAuthenticationToken
はJsonRequestAuthenticationFilter
のスーパークラスであるAbstractAuthenticationProcessingFilter
のsuccessfulAuthentication
メソッドでSecurityContext
に設定されるため、最終的にセッションとしてRedisに保存されることとなる。
RestController
@PreAuthorize
を設定していないAPIは認証不要である。そのため、WebSecurityConfig
で設定している/api/login
以外に、ユーザー登録APIである/api/signup
等には認証不要でアクセスできる。
/api/signup
ではユーザー登録成功時にはログイン済みとみなしたいため、以下のコードを実行している。
SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken
によるセッション登録
また認証を要しているかどうかに関わらず、@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): 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)
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":"p@ssw0rd"}' \
--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"