🔐

Spring Securityを使ってJWT認証・認可処理を実装してみた

に公開

こんにちは!Septeni Japan株式会社の大志万と言います。

最近Spring SecurityとJWTを使った認証に触る機会がありました。
私自身、Spring Securityを触るのは初めてだったので、Spring Securityへの理解を深めたいという思いからアプリの作成とブログの執筆を行いました。

前提知識

  • Spring Bootの基本的な知識
  • JWTの基本的な概念
  • Java/Kotlinの基本的なプログラミング知識
    • 本ブログのサンプルコードはKotlinを用いて実装しています。

Spring Securityとは

Spring Securityは、Spring Frameworkのセキュリティモジュールで、アプリケーションに認証・認可機能を追加できるフレームワークです。Java Servletの機能を活用してセキュリティ機能を提供します。

Spring Securityの概要については下記の記事がとてもわかりやすかったので、ご確認ください。
https://qiita.com/suger4/items/39b37af3705f2d0271c7#spring-securityの仕組み

JWT検証の流れ

作成するアプリケーション

jwt_flow

今回は、上記のように認可されたユーザーにはHello Worldを返却する簡単なアプリケーションを作成します。
簡易的な検証のため、今回は認証情報をユーザー名のみにしています。

jwt_admin
また、JWTにroleのクレームを付与し、Admin というロールを持つユーザーだけが、/adminエンドポイントにアクセスできるという機能も実装したいと思います。

実装方針

Security Filterについて

Spring Securityでは、Security Filterという、いくつかの連続したチェックポイントを適用することでセキュリティを確保するようになっています。

各フィルターが特定のセキュリティ責務(認証、認可、CSRF 対策、セッション管理など)を担当し、処理が終わると次のフィルターに処理を委譲します。

フィルターは適切に機能するように実行する順序が決まっています。
例えば、認証を実行するフィルターは認可を実行するフィルターよりも前に実行されなければならないためです。
フィルターの処理の順序はこちらをご確認ください。

検証方法

JWTの検証とユーザーの認証をするSecurity Filterを新しく作成し、認証したユーザー情報をコンテキストに設定して後続のフィルタで使えるようにします。
後続のFilterで設定したコンテキストを使って認可処理を行うようにします。

security_filter

実装

AuthController

@RestController
class AuthController(
    private val userRepository: InMemoryUserRepository,
    private val jwtTokenProvider: JwtTokenProvider,
) {
    data class LoginRequest(
        val username: String,
    )

    data class AuthResponse(
        val token: String,
    )

    @PostMapping("/login")
    fun login(
        @RequestBody request: LoginRequest,
    ): ResponseEntity<AuthResponse> {
        val user = userRepository.findBy(request.username) ?: return ResponseEntity.badRequest().build()
        val token = jwtTokenProvider.createToken(user.username, user.roles)
        return ResponseEntity.ok(AuthResponse(token))
    }
}

/loginエンドポイントの実装です。
今回はJWTの検証をメインで扱いたいため簡略化のために、ユーザー名があっていればアクセストークンを返却するようにしています。

JwtTokenProvider

@Component
class JwtTokenProvider {
    private val secret = "secret"

    private val issuer = "SpringSecurityDemo"

    fun createToken(
        username: String,
        roles: List<String>,
    ): String {
        val algorithm = Algorithm.HMAC512(secret)

        val now = LocalDateTime.now()
        val expiresAt = now.plusHours(1)

        val jwt =
            JWT
                .create()
                .withIssuedAt(Date())
                .withIssuer(issuer)
                .withArrayClaim("roles", roles.toTypedArray())
                .withSubject(username)
                .withExpiresAt(expiresAt.toInstant(ZoneOffset.UTC))

        return jwt.sign(algorithm)
    }

    fun validateToken(token: String) {
        val algorithm = Algorithm.HMAC512(secret)
        val verifier: JWTVerifier = JWT.require(algorithm).withIssuer(issuer).build()
        verifier.verify(token)
    }

    fun getAuthentication(token: String): Authentication {
        validateToken(token)
        val decodedJwt = JWT.decode(token)

        val username = decodedJwt.subject
        val roles = decodedJwt.getClaim("roles").asList(String::class.java)
        val authorities = roles.map { SimpleGrantedAuthority(it.toString()) }

        val user = JwtUserDetails(username = username, authorities = authorities)
        return UsernamePasswordAuthenticationToken(user, null, authorities)
    }
}

JWTの作成・検証とJWTから認証情報を取得するユーティリティクラスです。

JwtAuthenticationFilter

@Component
class JwtAuthenticationFilter(
    private val jwtTokenProvider: JwtTokenProvider,
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        val token = resolveToken(request)

        if (token != null) {
            val authentication = jwtTokenProvider.getAuthentication(token)
            SecurityContextHolder.getContext().authentication = authentication
        }

        filterChain.doFilter(request, response)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearerToken = request.getHeader("Authorization")
        return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            bearerToken.substring(7)
        } else {
            null
        }
    }
}

認証情報をJWTから取得し、コンテキストに設定するセキュリティフィルターです。

SecurityConfig

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtAuthenticationFilter: JwtAuthenticationFilter,
) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/login").permitAll()
                    .requestMatchers("/admin/**").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
            }.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)

        return http.build()
    }
}

(コードブロック内では/**以降がコメントアウトとして判定されてますが、コメントアウトではありません。)

HttpSecurityオブジェクトを使って認証・認可処理の設定を行い、buildをすることで自動的にSecurityFilterの設定と、 SecurityFilterChainが作成されます。

jwtAuthenticationFilter をSpring Securityの標準フィルターである UsernamePasswordAuthenticationFilter の前に挿入して、認可処理の前に認証情報を設定することができます。

また、認可処理はauthorizeHttpRequestsによって設定することができ、下記のように設定しています。

  • /loginエンドポイントは、ログイン処理を行うために、未認証のユーザーでもアクセス可能です。(permitAll())
  • /admin/**パターンに一致するエンドポイントは、ADMINロールを持つユーザーのみアクセス可能です。hasAuthorityはどこの値を見て判定しているかというと、UsernamePasswordAuthenticationToken(user, null, authorities)で設定してあるauthoritiesになります。
  • その他すべてのエンドポイントは、認証されたユーザーのみアクセス可能です(authenticated())。

ResponseController

@RestController
class ResponseController {
    @GetMapping("/admin")
    fun admin(
        @AuthenticationPrincipal userDetails: JwtUserDetails,
    ): String = "Hello Admin, ${userDetails.username}"

    @GetMapping("/user")
    fun user(
        @AuthenticationPrincipal userDetails: JwtUserDetails,
    ): String = "Hello User, ${userDetails.username}"
}

認証したユーザーは @AuthenticationPrincipalアノテーションを使うことで認証したユーザーの情報を取得することができます

動作確認

まずuserとして、/loginエンドポイントにアクセスして、トークンを取得します
user_login

取得したトークンを使って、/userエンドポイントにアクセスするとレスポンスが帰ってくることが確認できました。
user_response_successfly

一方で/adminエンドポイントでは、アクセスが拒否されることが確認できました。
admin_response_failed

そこでadminとしてログインして取得したトークンを使って、/adminエンドポイントにアクセスすると無事にレスポンスが帰ってくることが確認できました。
admin_response_successfly

感想

Spring Securityを使って、実際に手を動かして実装してみることで、JWTやSpring Securityの機能を理解することができました。
今回はJWTを扱いましたが、Spring Securityにはさまざまな機能があるので、別の認証方法の実装もやってみたいです。

最後までお読みいただき、ありがとうございました!
質問やフィードバックがありましたら、ぜひコメント欄で教えてください。

今回作成したコードは下記のGithubに挙げているので興味がある人は見てみてください
https://github.com/septeni/spring-security-example

参考文献

Discussion