🦔

Spring Security で JWT を使用し認可の判定を行う

2023/01/26に公開
2

はじめに

これまで、 Spring Security の挙動をソースコードを追いながら見ていきました。

https://zenn.dev/kiyotatakeshi/articles/fc593c768ad7e0
https://zenn.dev/kiyotakeshi/articles/cf198ab5e6c735/
https://zenn.dev/kiyotatakeshi/articles/73f722f99b7bf5

今回の記事では、サンプルコード をもとに Spring Security が認可の判定を行う流れと、
認証時に JWT を発行し、以降のリクエストに JWT を付与することで認証は行わずに、 JWT に含まれる権限でアクセス制御をする方法を確認していきます(そのためにカスタムで定義した filter を差し込みます)。

今回の作業ブランチは use-jwt です。
(差分は こちらの merge commit より確認できます)

Spring Security が設定してくれている内容と、フレームワーク内部の実装を掘り下げることで、
セキュリティを意識する上で必要な実装についての理解を深められればと思います。

ライブラリ バージョン Maven central URL
spring-boot-starter-web 3.0.1 https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web/3.0.1
spring-boot-starter-security 3.0.1 https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security/3.0.1
spring-core 6.0.3 https://mvnrepository.com/artifact/org.springframework/spring-core/6.0.3
spring-security-web 6.0.1 https://mvnrepository.com/artifact/org.springframework.security/spring-security-web/6.0.1

Spring Security が認可の判定を行う流れ

SecurityFilterChain をカスタマイズしたものをBean定義することで、リクエストのパスごとに権限を見てアクセスの制御が行なえます。

デフォルトの設定では認可の設定が入っているかを確認してみます。

以前の記事で確認したように、 org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration が Auto-configuration でDIコンテナに登録されていますが、

@Import({ SpringBootWebSecurityConfiguration.class, SecurityDataConfiguration.class })
public class SecurityAutoConfiguration {

そこから @Import で読み込まれ、DIコンテナに登録されている org/springframework/boot/autoconfigure/security/servlet/SpringBootWebSecurityConfiguration.java に、
デフォルトの SecurityFilterChain の定義があります。

以下の定義により、 spring-boot-starter-security の導入後に、
Basic認証が適用され、ログイン画面が表示されます。

ただし、リソースへのアクセスについては、
http.authorizeHttpRequests().anyRequest().authenticated(); の設定が示すように、認可の判定はなく認証されたユーザがすべてのリソースにアクセスできるようになっています。

class SpringBootWebSecurityConfiguration {

	/**
	 * The default configuration for web security. It relies on Spring Security's
	 * content-negotiation strategy to determine what sort of authentication to use. If
	 * the user specifies their own {@link SecurityFilterChain} bean, this will back-off
	 * completely and the users should specify all the bits that they want to configure as
	 * part of the custom security configuration.
	 */
	@Configuration(proxyBeanMethods = false)
	@ConditionalOnDefaultWebSecurity
	static class SecurityFilterChainConfiguration {

		@Bean
		@Order(SecurityProperties.BASIC_AUTH_ORDER)
		SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
			http.authorizeHttpRequests().anyRequest().authenticated();
			http.formLogin();
			http.httpBasic();
			return http.build();
		}

	}

これをオーバーライドしたものをBean定義することで、
パスベースで権限を見てアクセスできるかを判定するように認可を適用できます。

今回の設定だと、パスベースで以下のようにリソースへのアクセス制御ができています。

  • ユーザの登録(/register)と公開リソース(/public)は認証無しでアクセス可能
  • /private は権限 ADMIN, USER を持つ認証済みユーザのみアクセス可能
  • /roles, /customers は権限の追加やユーザへの付与を行うパスとしているため、 ADMIN を持つ認証済みユーザのみアクセス可能
  • /me は認証済みユーザのみアクセス可能(権限は不要)
    @Bean
    @Throws(Exception::class)
    fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain? {
        http
            .authorizeHttpRequests {
                it.requestMatchers("/register", "/public").permitAll()
                    .requestMatchers("/private").hasAnyRole("ADMIN", "USER")
                    .requestMatchers("/roles", "/customers/**").hasRole("ADMIN")
                    .requestMatchers("/me").authenticated()
            }
            .formLogin()
            .and().httpBasic()
            // TODO: enable
            .and().csrf().disable()
        return http.build()
    }

では、どのように認可の判定が行われているかを追ってみます。

AuthorizationManager(ドキュメント) が AuthorizationFilter から呼び出されアクセス制御を行っています。

AuthorizationManagers are called by the AuthorizationFilter and are responsible for making final access control decisions. The AuthorizationManager interface contains two methods:

AuthorizationFilter(ドキュメント) は FilterChainProxy の中の1つの filter として組み込まれ、認可を提供しています。

The AuthorizationFilter provides authorization for HttpServletRequests. It is inserted into the FilterChainProxy as one of the Security Filters.

以前の記事で Spring Security により適用されている filter の一覧を確認したように、
AuthorizationFilter は一番最後に適用される filter です。

> this.securityFilterChains.get(0).getFilters()
result = {ArrayList@6842}  size = 15
 0 = {DisableEncodeUrlFilter@6845} 
 1 = {WebAsyncManagerIntegrationFilter@6846} 
 2 = {SecurityContextHolderFilter@6847} 
 3 = {HeaderWriterFilter@6848} 
 4 = {CsrfFilter@6849} 
 5 = {LogoutFilter@6850} 
 6 = {UsernamePasswordAuthenticationFilter@6851} 
 7 = {DefaultLoginPageGeneratingFilter@6852} 
 8 = {DefaultLogoutPageGeneratingFilter@6853} 
 9 = {BasicAuthenticationFilter@6854} 
 10 = {RequestCacheAwareFilter@6855} 
 11 = {SecurityContextHolderAwareRequestFilter@6856} 
 12 = {AnonymousAuthenticationFilter@6857} 
 13 = {ExceptionTranslationFilter@6858} 
 14 = {AuthorizationFilter@6859} 

AuthorizationFilter のドキュメントの図を確認すると流れがわかりやすいです。
authorizationfilter.png

  1. 順次 filter の処理を適用し AuthorizationFilter が処理される
  2. AuthorizationFilterSecurityContextHolder から認証を取得
  3. AuthorizationManager (これは interface) に処理が渡され、実装である RequestMatcherDelegatingAuthorizationManager が認可を判定

という流れになります。

実際にデバック実行して確認してみます。

サンプルコードの README.md を実行してテストデータを用意します。
(DDL,DML を流している Makefile はこちら)

$ docker compose up -d

$ make init-db show-sample-data

検証なので、ログレベルを TRACE にしておきます。

logging.level.org.springframework.security: TRACE

この状態で、認証および認可が必要なエンドポイントにリクエストします。

  • /private は権限 ADMIN, USER を持つ認証済みユーザのみアクセス可能
$ admin_encoded_credential=$(echo -n "admin:1qazxsw2" | base64)
$ curl --location --request GET 'localhost:9080/private' --header "Authorization: Basic $admin_encoded_credential"

hello public world

org/springframework/security/web/access/intercept/RequestMatcherDelegatingAuthorizationManager.javacheck メソッドに breakpoint を張ると入ってきました。

stacktrace をみると、呼び出し元が AuthorizationFilter であることがわかります。

15-zenn-spring-security

なお、ログレベルを TRACE にしていることで
filter が順に呼ばれていることが確認でき、 this.logger.isTraceEnabled() の箇所も true となりログ出力されます。

2023-01-26T12:44:07.953+09:00 TRACE 53949 --- [nio-9080-exec-1] o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (17/18)
2023-01-26T12:44:07.953+09:00 TRACE 53949 --- [nio-9080-exec-1] o.s.security.web.FilterChainProxy        : Invoking AuthorizationFilter (18/18)

2023-01-26T12:47:24.594+09:00 TRACE 53949 --- [nio-9080-exec-1] estMatcherDelegatingAuthorizationManager : Authorizing SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@7f3e9de6]

SecurityFilterChain をオーバーライドしてパスごとの権限を設定したものが RequestMatcherEntry として渡されています。
16-zenn-spring-security

2023-01-26T13:01:54.571+09:00 TRACE 53949 --- [nio-9080-exec-3] estMatcherDelegatingAuthorizationManager : Checking authorization on SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@348845c5] using AuthorityAuthorizationManager[authorities=[ROLE_ADMIN, ROLE_USER]]

その後、org/springframework/security/authorization/AuthorityAuthorizationManager.java が呼ばれ、認証したユーザが権限を持っているかを判定しています。

	private boolean isAuthorized(Authentication authentication) {
		Set<String> authorities = AuthorityUtils.authorityListToSet(this.authorities);
		for (GrantedAuthority grantedAuthority : getGrantedAuthorities(authentication)) {
			if (authorities.contains(grantedAuthority.getAuthority())) {
				return true;
			}
		}
		return false;
	}

AuthorizationFilter -> AuthorizationManager -> RequestMatcherDelegatingAuthorizationManager -> AuthorityAuthorizationManager と処理されていくことを確認できました。

JWT に含まれる権限情報で認可を判定する

以前の記事でも参照した、パスワードの保存に関するメカニズムの進化の歴史を記載したドキュメント に以下の記載があります。

Because adaptive one-way functions are intentionally resource intensive, validating a username and password for every request can significantly degrade the performance of an application. There is nothing Spring Security (or any other library) can do to speed up the validation of the password, since security is gained by making the validation resource intensive. Users are encouraged to exchange the long term credentials (that is, username and password) for a short term credential (such as a session, and OAuth Token, and so on). The short term credential can be validated quickly without any loss in security.

パスワードを検証するためのハッシュ化は、Brute-force attack への対策としてあえて時間がかかるものにしてあり、
ユーザが都度認証を実施するとシステムのパフォーマンスの低下に繋がります。
そのため、ユーザーが長期のクレデンシャル(ユーザー名とパスワード)ではなく、
短期のクレデンシャル(セッション、JWT など)を使用できるようにするのが良い
とのことです。

これを実現するために、認証時に JWT を発行し、以降のリクエストに JWT を付与することで認証は行わずに、 JWT に含まれる権限でアクセス制御できるようにします。


Spring Security にはJWT をチェックする機能が用意されています。(今回は spring-boot-starter-oauth2-resource-server を使用)
making さんコメントにて教えて頂きました!

そのため、これから紹介する方法は独自の処理を定義した filter を差し込む方法の参考までに残しておきますが、 JWT の検証は Spring Security が用意しているものを使用しましょう!

Spring Security が用意している JWT のサポートの紹介は後半に記載しています!


以下のように SecurityFilterChain のBean定義にて自分で定義した filter を差し込むようにします。

  • Basic認証を使用する場合は Header の形式が有効化を確認する filter

    • .addFilterBefore(AuthorizationHeaderValidationFilter(), BasicAuthenticationFilter::class.java)
  • Basic認証の前に JWT を検証する filter

    • .addFilterBefore(JWTValidationFilter(), BasicAuthenticationFilter::class.java)
  • Basic認証か JWT の検証の後にユーザの権限をログ出力する filter

    • .addFilterAfter(LoggingAuthoritiesFilter(), BasicAuthenticationFilter::class.java)
  • Basic認証後に JWT を発行する filter

    • .addFilterAfter(JWTGenerationFilter(), LoggingAuthoritiesFilter::class.java)
@Configuration
class SecurityConfig {

    @Bean
    @Throws(Exception::class)
    fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain? {
        http
            .authorizeHttpRequests {
                it.requestMatchers("/register", "/public").permitAll()
                    .requestMatchers("/private").hasAnyRole("ADMIN", "USER")
                    .requestMatchers("/roles", "/customers/**").hasRole("ADMIN")
                    .requestMatchers("/me").authenticated()
            }
            .formLogin()
            .and().httpBasic()
            .and().csrf().disable()
            .addFilterBefore(AuthorizationHeaderValidationFilter(), BasicAuthenticationFilter::class.java)
            .addFilterBefore(JWTValidationFilter(), BasicAuthenticationFilter::class.java)
            .addFilterAfter(LoggingAuthoritiesFilter(), BasicAuthenticationFilter::class.java)
            .addFilterAfter(JWTGenerationFilter(), LoggingAuthoritiesFilter::class.java)
        return http.build()
    }

JWT を生成する filter からみていきます。
Basic認証後に認証情報を取得し、 JWT を生成します。
JWT の生成のライブラリは jwtkを使用しています。

この filter の処理を行うのは /me にリクエストが来たときだけにします。

class JWTGenerationFilter : OncePerRequestFilter() {

    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        val authentication = SecurityContextHolder.getContext().authentication
        if (null != authentication) {
            val jwt = generateJWT(authentication)
            response.setHeader(HttpHeaders.AUTHORIZATION, jwt)
        }
        filterChain.doFilter(request, response)
    }

    /**
     * only exec filter `/me`
     */
    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        return request.servletPath != "/me"
    }

    private fun generateJWT(authentication: Authentication): String {
        val key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.toByteArray(StandardCharsets.UTF_8))
        val now = Date()
        val eightHoursAfter = Date(now.time + (1000 * 60 * 60 * 8))
        return Jwts.builder()
            .setIssuer("Spring Security Sample")
            .setSubject("JWT")
            .claim("username", authentication.name)
            .claim("roles", authentication.authorities
                .map { it.authority }
                .toSet()
            )
            .setIssuedAt(now)
            .setExpiration(eightHoursAfter)
            .signWith(key)
            .compact()
    }

次に JWT を検証する filter を見ていきます。
こちらはリクエストの Authorization HeaderBearer から始まる値が指定されていた時に、JWT を取り出して検証します。

JWT が有効なものであれば、そこから権限情報を取り出して、SecurityContextHolder に保持します。

class JWTValidationFilter : OncePerRequestFilter() {
    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(
        req: HttpServletRequest,
        res: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        val header = req.getHeader(HttpHeaders.AUTHORIZATION)
        // Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTcHJpbmcgU2VjdXJpdHkgU2FtcGxlIiwic3ViIjoiSldUIiwidXNlcm5hbWUiOiJtaWtlQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiUk9MRV9URVNUIiwiUk9MRV9VU0VSIl0sImlhdCI6MTY3NDQ4NjUzNSwiZXhwIjoxNjc0NTE2NTM1fQ.IK4T9hIDikFQuI4hQIhm_z4sih_yYK7GEtPO9nwDEmE
        if (null != header) {
            if (StringUtils.startsWithIgnoreCase(header, "Bearer")) {
                val base64Token = header.substring(7)
                try {
                    val key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.toByteArray((StandardCharsets.UTF_8)))
                    val claims = Jwts.parserBuilder()
                        .setSigningKey(key)
                        .build()
                        .parseClaimsJws(base64Token)
                        .body

                    val roles = claims["roles"] as ArrayList<*>
                    val auth: Authentication = UsernamePasswordAuthenticationToken(
                        claims["username"].toString(),
                        null,
                        AuthorityUtils.commaSeparatedStringToAuthorityList(roles.joinToString(", "))
                    )
                    SecurityContextHolder.getContext().authentication = auth
                }
                catch (e: ExpiredJwtException) {
                    throw BadCredentialsException("Token is expired!\n${e.printStackTrace()}")
                }
                catch (e: Exception) {
                    throw BadCredentialsException("Invalid Token received!\n${e.printStackTrace()}")
                }
            }
        }
        filterChain.doFilter(req, res)
    }

    /**
     * only not exec filter `/me`
     */
    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        return request.servletPath == "/me"
    }

org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java は Authorization Header に Basic の付与がないと、
ProviderManager 以降が呼び出され DB にユーザを取得しに行く箇所の処理には入らないです。

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
			if (authRequest == null) {
				this.logger.trace("Did not process authentication request since failed to find "
						+ "username and password in Basic Authorization header");
				chain.doFilter(request, response);
				return;
			}

※ログレベルを TRACE にして確認したもの

2023-01-26T13:54:15.875+09:00 TRACE 53949 --- [nio-9080-exec-9] o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (11/18)
2023-01-26T13:54:15.876+09:00 TRACE 53949 --- [nio-9080-exec-9] o.s.s.w.a.www.BasicAuthenticationFilter  : Did not process authentication request since failed to find username and password in Basic Authorization header
2023-01-26T13:54:15.876+09:00 TRACE 53949 --- [nio-9080-exec-9] o.s.security.web.FilterChainProxy        : Invoking LoggingAuthoritiesFilter (12/18)

つまり Authorization HeaderBasic を付与せずに Bearer を付与してリクエストした場合は JWT の検証を行い、
認可権限の判定に使用する認証情報を保持するようにするのです。

残りのカスタムの filter は重要ではないので、ソースコードへのリンクを付与し説明は割愛します。

Basic認証を使用する場合は Header の形式が有効化を確認する filter
Basic認証か JWT の検証の後にユーザの権限をログ出力する filter


Basic認証を使用してアクセスする場合と、認証後に発行された JWT を使用してアクセスする場合の簡単なパフォーマンスの比較をしてみます。

Postman の collection を import してリクエストしてみます。

Basic認証を使用してアクセスする場合は DB まで取りに行く必要があるので 126ms でした。
17-zenn-spring-security

一方 JWT を検証する場合は 14ms でした。

18-zenn-spring-security

この簡易的な結果からも、以下のことが確認できました。

ユーザーが長期のクレデンシャル(ユーザー名とパスワード)ではなく、
短期のクレデンシャル(セッション、JWT など)を使用できるようにするのが良い

セキュリティの要件に合わせて、 JWT を使用しても問題ない場合は、
都度 DB までユーザを取得して認証を行わない方法も使用できそうです。
※今回は JWT の有効期限を8hとしています

Spring Scurity の JWT サポートを使用する

making さんコメントにて教えて頂いた spring-security-oauth2-resource-server を使用して JWT を使った認証を行います。

同じくコメントにて教えて頂いた以下のハンズオンも大変分かりやすいです。

https://www.danvega.dev/blog/2022/09/06/spring-security-jwt/

branch use-spring-security-oauth2-resource-server に checkout することで動かして確認できます。

OAuth 2.0 Resource Server JWT のドキュメントを参考に、
spring-boot-starter-oauth2-resource-server を使用することで、 OAuth 2.0 の Resource Server としての必要な機能を実装できます。

ただし、今回の例では Authorization Server は存在せず、 self-signed JWT を使用します(認証と JWT の発行元が Resourse Server 自身)。

アプリを起動し JWT を発行後に、JWT をリクエストに付与( Authorization HeaderBearer で指定)したときの処理を追ってみます。

ドキュメントの How JWT Authentication Works に記載の図の流れで JWT を decode して認証に成功したら SecurityContextHolder に情報が保持されます。

jwtauthenticationprovider.png

まず、適用されている filter を確認すると、
BearerTokenAuthenticationFilterBasicAuthenticationFilter の前に差し込まれています。

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  LogoutFilter
  BearerTokenAuthenticationFilter
  BasicAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]

org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java に breakpoint を貼った状態で、 JWT を付与したリクエストをすると、

BearerTokenAuthenticationToken -> AuthenticationManager -> JwtAuthenticationProvider と呼び出されていることが確認できます。

19-zenn-spring-security.png

DIコンテナに登録しておいた jwtDecoder が使われ、
リクエストの JWT を検証して org/springframework/security/oauth2/jwt/Jwt.java にマッピングしてくれます。

	private Jwt getJwt(BearerTokenAuthenticationToken bearer) {
		try {
			return this.jwtDecoder.decode(bearer.getToken());
		}

その後は、
org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationConverter.java が JWT 内の claimscope を見て

	private Collection<String> getAuthorities(Jwt jwt) {
		String claimName = getAuthoritiesClaimName(jwt);
		if (claimName == null) {
			this.logger.trace("Returning no authorities since could not find any claims that might contain scopes");
			return Collections.emptyList();
		}
		if (this.logger.isTraceEnabled()) {
			this.logger.trace(LogMessage.format("Looking for scopes in claim %s", claimName));
		}
		Object authorities = jwt.getClaim(claimName);

// 略

Collection<GrantedAuthority> に変換してくれます。
これにより、 JWT に含まれている認可の情報を判定に使用できます。

	@Override
	public Collection<GrantedAuthority> convert(Jwt jwt) {
		Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
		for (String authority : getAuthorities(jwt)) {
			grantedAuthorities.add(new SimpleGrantedAuthority(this.authorityPrefix + authority));
		}
		return grantedAuthorities;
	}

なお、JwtAuthenticationConverter の変換はデフォルトだと、
SCOPE_ の prefix がつくためカスタマイズしたものをBean定義しています。

※JWT 作成時に scopeROLE_ の prefix を付与しているため、SCOPE_ の prefix を取り除くようにカスタマイズしました。

    @Bean
    fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
        val grantedAuthoritiesConverter = JwtGrantedAuthoritiesConverter()
	// prefix を取り除く
        grantedAuthoritiesConverter.setAuthorityPrefix("")

        val jwtAuthenticationConverter = JwtAuthenticationConverter()
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter)
        return jwtAuthenticationConverter
    }

BearerTokenAuthenticationFilter が呼び出されるところから、最後の filter である AuthorizationFilter が呼び出し終わるまでのログ(log level=TRACE)が以下のものです。

BasicAuthenticationFilter 内で DB からユーザを取得して認証を行っていないので、
先程 Postman で検証した時同様に、レスポンスは早くなっています。

2023-01-28T00:56:41.398+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking BearerTokenAuthenticationFilter (6/14)
2023-01-28T00:56:41.407+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.s.authentication.ProviderManager     : Authenticating request with JwtAuthenticationProvider (1/2)
2023-01-28T01:20:56.141+09:00 TRACE 69125 --- [nio-9080-exec-3] s.o.s.r.a.JwtGrantedAuthoritiesConverter : Looking for scopes in claim scope
2023-01-28T01:23:45.387+09:00 DEBUG 69125 --- [nio-9080-exec-3] o.s.s.o.s.r.a.JwtAuthenticationProvider  : Authenticated token
2023-01-28T01:24:08.556+09:00 DEBUG 69125 --- [nio-9080-exec-3] .s.r.w.a.BearerTokenAuthenticationFilter : Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@55e006a8, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ADMIN, ROLE_USER]]
2023-01-28T01:24:08.556+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking AuthorizationHeaderValidationFilter (7/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (8/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.s.w.a.www.BasicAuthenticationFilter  : Did not process authentication request since failed to find username and password in Basic Authorization header
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking LoggingAuthoritiesFilter (9/14)
2023-01-28T01:24:08.557+09:00  INFO 69125 --- [nio-9080-exec-3] c.e.z.security.LoggingAuthoritiesFilter  : User "admin@example.com" is successfully authenticated and has the authorities [ROLE_ADMIN, ROLE_USER]
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking RequestCacheAwareFilter (10/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.s.w.s.HttpSessionRequestCache        : matchingRequestParameterName is required for getMatchingRequest to lookup a value, but not provided
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderAwareRequestFilter (11/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking AnonymousAuthenticationFilter (12/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (13/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.security.web.FilterChainProxy        : Invoking AuthorizationFilter (14/14)
2023-01-28T01:24:08.557+09:00 TRACE 69125 --- [nio-9080-exec-3] estMatcherDelegatingAuthorizationManager : Authorizing SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@12699b6b]
2023-01-28T01:24:08.562+09:00 TRACE 69125 --- [nio-9080-exec-3] estMatcherDelegatingAuthorizationManager : Checking authorization on SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@12699b6b] using AuthorityAuthorizationManager[authorities=[ROLE_ADMIN, ROLE_USER]]
2023-01-28T01:24:08.566+09:00 TRACE 69125 --- [nio-9080-exec-3] o.s.s.w.a.AnonymousAuthenticationFilter  : Did not set SecurityContextHolder since already authenticated JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@55e006a8, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ADMIN, ROLE_USER]]

おわりに

Spring Security の認可の流れと JWT を使用した認可の判定を追加しました。
独自の処理を実現する filter を任意の場所に組み込めるので、
要件に合わせた柔軟なカスタマイズが可能となることがわかりました。

これまで数回の記事で見てきたように、
Spring Security のフレームワークとしての決まりごとに乗っかることで、
アプリケーションにとって必要なセキュリティをフルスクラッチで作ることなく、
それでいて高いカスタマイズ性を持って実現可能なのは魅力的でした。

今後、別の言語やフレームワークで認証周りを実装する時に、
Spring Security の思想や考慮しているセキュリティ要件が大変参考になると感じました。

Discussion

ponkan1219ponkan1219

存じ上げていなかったです!

ドキュメントを確認し、紹介いただいたチュートリアルをやってみましたが、
spring-security-oauth2-resource-server を使用することで、
BearerTokenAuthenticationFilterBasicAuthenticationFilter の前に差し込まれて、
今回実現したかったことがまさにできました!

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  LogoutFilter
  BearerTokenAuthenticationFilter
  BasicAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]
2023-01-26T23:28:07.466+09:00 TRACE 62209 --- [nio-8080-exec-5] o.s.security.web.FilterChainProxy        : Invoking BearerTokenAuthenticationFilter (6/13)
2023-01-26T23:28:07.467+09:00 TRACE 62209 --- [nio-8080-exec-5] o.s.s.authentication.ProviderManager     : Authenticating request with JwtAuthenticationProvider (1/2)
2023-01-26T23:28:07.478+09:00 TRACE 62209 --- [nio-8080-exec-5] s.o.s.r.a.JwtGrantedAuthoritiesConverter : Looking for scopes in claim scope
2023-01-26T23:28:07.479+09:00 DEBUG 62209 --- [nio-8080-exec-5] o.s.s.o.s.r.a.JwtAuthenticationProvider  : Authenticated token
2023-01-26T23:28:07.479+09:00 DEBUG 62209 --- [nio-8080-exec-5] .s.r.w.a.BearerTokenAuthenticationFilter : Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@b3d76743, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_EDITOR, SCOPE_READ]]
2023-01-26T23:28:07.479+09:00 TRACE 62209 --- [nio-8080-exec-5] o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (7/13)
2023-01-26T23:28:07.479+09:00 TRACE 62209 --- [nio-8080-exec-5] o.s.s.w.a.www.BasicAuthenticationFilter  : Did not process authentication request since failed to find username and password in Basic Authorization header

記事とサンプルコードは後ほど修正します! -> 修正しました!
情報ありがとうございました。