環境

  • 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で設定する。

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

WebSecurityConfig

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

ログインで重要な点はfun configure(http: HttpSecurity)内の以下の3点。


  1. 独自に作成したJsonRequestAuthenticationFilter/api/loginに紐付けている。
  2. 200 OKを返す: Form認証では認証成功後にリダイレクトするようになっている。REST APIでは200 OKとしたい。
  3. レスポンスヘッダーで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リストを保存するなど作り込みが必要なため、今回は割愛する。