環境
- Kotlin 1.6
- Spring Boot 2.6.0
- Spring Security 5.6.0
- com.auth0:java-jwt 3.18.2
Gradleの設定含め詳細はGitHubにて記載
関連
Spring SecurityでREST API + JSONによる認証を行う(Session/Cookie + Redis編)
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と認証要件が同じ場所に書かれる方がわかりやすいから。
WebSecurityConfig
早速Spring Securityの設定の要となるWebSecurityConfigurerAdapter
を継承したWebSecurityConfig
のコードを書いていく。
ログインで重要な点はfun configure(http: HttpSecurity)
内の以下の3点。
- 独自に作成した
JsonRequestAuthenticationFilter
を/api/login
に紐付けている。 - 200 OKを返す: Form認証では認証成功後にリダイレクトするようになっている。REST APIでは200 OKとしたい。
- レスポンスヘッダーで
X-AUTH-TOKEN
を返す。
ログイン後の認証で重要な点は、独自に作成したJWTTokenFilter
をFilterとして全てのAPIの前処理として通すことである。
また、JWT認証ではセッションを使わないため、session fixation対策は不要となる。
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // for @PreAuthorize, @Secured
class WebSecurityConfig(
private val jsonRequestAuthenticationProvider: JsonRequestAuthenticationProvider,
private val jwtProvider: JWTProvider,
private val objectMapper: ObjectMapper
) : WebSecurityConfigurerAdapter() {
override fun configure(web: WebSecurity) {
web.ignoring().antMatchers("/images/**", "/js/**", "/css/**")
}
override fun configure(http: HttpSecurity) {
http.csrf().disable() // Cookie/Sessionを利用しないため不要
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
val jsonAuthFilter = JsonRequestAuthenticationFilter(objectMapper)
jsonAuthFilter.setRequiresAuthenticationRequestMatcher(AntPathRequestMatcher("/api/login", "POST"))
jsonAuthFilter.setAuthenticationSuccessHandler { _, response, auth ->
val authToken = jwtProvider.createToken(auth.principal as LoginUser)
response.setHeader(X_AUTH_TOKEN, authToken)
response.status = 200
}
jsonAuthFilter.setAuthenticationManager(authenticationManagerBean())
http.addFilter(jsonAuthFilter)
http.addFilterBefore(JWTTokenFilter(jwtProvider), JsonRequestAuthenticationFilter::class.java)
}
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でユーザーを検索して、パスワードがマッチしているかどうか確認している。
@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, null, loginUser.authorities)
}
override fun supports(authentication: Class<*>): Boolean {
return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
}
}
ログイン後の毎アクセスでのJWT検証処理
JWTTokenFilter
独自に作成したJWTTokenFilter
はFilterとして全てのAPIの前処理として実行される。
処理の詳細はJWTProvider
に記載しているが、大まかにはリクエストのX-AUTH-TOKEN
の正当性を確認し、問題なければSecurityContext
に認証したユーザーの情報を保持するLoginUser
という独自クラスをセットする。
SecurityContext
に設定したデータは@AuthenticationPrincipal
を付与したクラスにSpringがDIしてくれるため、JWTTokenFilter
の後に動くことになるControllerで認証ユーザーの情報を扱えるようになる。
class JWTTokenFilter(private val jwtProvider: JWTProvider) : GenericFilterBean() {
override fun doFilter(request: ServletRequest, response: ServletResponse?, chain: FilterChain) {
val token: String? = jwtProvider.getToken(request)
if (token != null) {
val decodedJWT = jwtProvider.verifyToken(token)
val loginUser = jwtProvider.retrieve(decodedJWT)
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(loginUser, null, loginUser.authorities)
}
chain.doFilter(request, response)
}
}
JWTProvider
JWTライブラリであるcom.auth0:java-jwtを使用して、JWTの生成や検証を行なっている。
@Component
class JWTProvider(@Value("${'$'}{jwt.secret}") secret: String) {
val algorithm: Algorithm = Algorithm.HMAC256(secret)
fun createToken(user: LoginUser): String {
val now = Date()
return JWT.create()
.withIssuer("com.example.nosessionjwt")
.withIssuedAt(now)
.withExpiresAt(Date(now.time + 1000 * 60 * 60))
.withSubject(user.id.toString())
.withClaim(CLAIM_ROLES, user.authorities.map { it.authority })
.sign(algorithm)
}
fun getToken(request: ServletRequest): String? {
val token: String? = (request as HttpServletRequest).getHeader(X_AUTH_TOKEN)
return token?.takeIf { it.startsWith("Bearer ") }?.substring(7)
}
fun verifyToken(token: String): DecodedJWT {
val verifier = JWT.require(algorithm).build()
return verifier.verify(token)
}
fun retrieve(decodedJWT: DecodedJWT): LoginUser {
val userId = decodedJWT.subject.toInt()
val roles = decodedJWT.getClaim(CLAIM_ROLES).asList(String::class.java)
return LoginUser(userId, roles.map { SimpleGrantedAuthority(it) })
}
companion object {
const val X_AUTH_TOKEN = "X-AUTH-TOKEN"
const val CLAIM_ROLES = "roles"
}
}
RestController
@PreAuthorize
を設定していないAPIは認証不要である。そのため、WebSecurityConfig
で設定している/api/login
以外に、ユーザー登録APIである/api/signup
等には認証不要でアクセスできる。
/api/signup
ではユーザー登録成功時にログイン時と同様、レスポンスヘッダーにX-AUTH-TOKEN
を設定している。
また認証されているかどうかに関わらず、@AuthenticationPrincipal
を付与したクラスに、JWTTokenFilter
にてSecurityContext
に設定したデータをSpringがDIしてくれる。
@SpringBootApplication
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
@RestController
class Controller(
private val userRepository: UserRepository,
private val jwtProvider: JWTProvider,
private val passwordEncoder: PasswordEncoder
) {
@PostMapping(path = ["/api/signup"], produces = [MediaType.APPLICATION_JSON_VALUE])
fun signup(@RequestBody body: EmailAndPasswordJsonRequest, httpServletResponse: HttpServletResponse): 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) })
val authToken = jwtProvider.createToken(loginUser)
httpServletResponse.setHeader(X_AUTH_TOKEN, authToken)
// ログイン済とみなす
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(loginUser, null, 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) }
}
動作確認
実際にcurlでアクセスして確認する。
すでに過去にユーザー登録済みであるとして、ログインするためにPOST /api/login
を実行したい。事前に未ログインであることを確認する。
$ curl -w"\n" localhost:8080/api/non-personal
everyone can see. not logged in.
$ curl -w"\n" localhost:8080/api/personal/user
{"timestamp":"2021-12-18T16:22:08.134+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"}' \
-H "Content-Type: application/json"
HTTP/1.1 200
X-AUTH-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNyIsInJvbGVzIjpbIlJPTEVfTk9STUFMIl0sImlzcyI6ImNvbS5leGFtcGxlLm5vc2Vzc2lvbmp3dCIsImV4cCI6MTYzOTg0ODE0NSwiaWF0IjoxNjM5ODQ0NTQ1fQ.BOb9blPNBI_oo4vj8toagx61PX1LJCc0ulLTcXBMvQA
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
Content-Length: 0
Date: Sat, 18 Dec 2021 16:22:25 GMT
200 OKが返り、ログインに成功したことと、X-AUTH-TOKEN
がeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNyIsInJvbGVzIjpbIlJPTEVfTk9STUFMIl0sImlzcyI6ImNvbS5leGFtcGxlLm5vc2Vzc2lvbmp3dCIsImV4cCI6MTYzOTg0ODE0NSwiaWF0IjoxNjM5ODQ0NTQ1fQ.BOb9blPNBI_oo4vj8toagx61PX1LJCc0ulLTcXBMvQAであることがわかる。
認証が必要なページにもアクセスできることがわかった。
$ curl -w"\n" localhost:8080/api/non-personal \
-H "X-AUTH-TOKEN: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNyIsInJvbGVzIjpbIlJPTEVfTk9STUFMIl0sImlzcyI6ImNvbS5leGFtcGxlLm5vc2Vzc2lvbmp3dCIsImV4cCI6MTYzOTg0ODE0NSwiaWF0IjoxNjM5ODQ0NTQ1fQ.BOb9blPNBI_oo4vj8toagx61PX1LJCc0ulLTcXBMvQA"
everyone can see. logged in.
$ curl -w"\n" localhost:8080/api/personal/user \
-H "X-AUTH-TOKEN: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNyIsInJvbGVzIjpbIlJPTEVfTk9STUFMIl0sImlzcyI6ImNvbS5leGFtcGxlLm5vc2Vzc2lvbmp3dCIsImV4cCI6MTYzOTg0ODE0NSwiaWF0IjoxNjM5ODQ0NTQ1fQ.BOb9blPNBI_oo4vj8toagx61PX1LJCc0ulLTcXBMvQA"
{"id":27,"email":"[email protected]","password":"{bcrypt}$2a$10$agXc47ddJfmu3TNNVuzaVOMuptOmF.ViThEuMJO32kAWx9fvRcosq","roles":["ROLE_NORMAL"]}
ログアウトはJWTの場合、Redisに無効JWTリストを保存するなど作り込みが必要なため、今回は割愛する。