OAuth 2.0 資源伺服器 JWT

JWT 的最少依賴

大多數資源伺服器的支援都集中在 spring-security-oauth2-resource-server 中。然而,用於解碼和驗證 JWT 的支援在 spring-security-oauth2-jose 中,這意味著兩者都是一個支援 JWT 編碼的 Bearer 令牌的資源伺服器正常工作所必需的。

JWT 的最少配置

使用 Spring Boot 時,將應用程式配置為資源伺服器包含兩個基本步驟。首先,包含所需的依賴項。其次,指明授權伺服器的位置。

指定授權伺服器

在 Spring Boot 應用中,你需要指定要使用的授權伺服器

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer

其中 idp.example.com/issuer 是授權伺服器釋出的 JWT 令牌中 iss 宣告包含的值。此資源伺服器使用此屬性進行進一步的自配置,發現授權伺服器的公鑰,並隨後驗證傳入的 JWT。

啟動期望

使用此屬性和這些依賴項時,資源伺服器會自動配置自身以驗證 JWT 編碼的 Bearer 令牌。

它透過一個確定性的啟動過程實現這一點

  1. 訪問 Provider Configuration 或 Authorization Server Metadata 端點,處理響應以獲取 jwks_url 屬性。

  2. 配置驗證策略以查詢 jwks_url 獲取有效的公鑰。

  3. 配置驗證策略以針對 idp.example.com 驗證每個 JWT 的 iss 宣告。

此過程的一個結果是,授權伺服器必須接收請求,以便資源伺服器成功啟動。

如果資源伺服器在查詢授權伺服器時(在適當的超時設定下)授權伺服器宕機,則啟動將失敗。

執行時期望

應用程式啟動後,資源伺服器會嘗試處理任何包含 Authorization: Bearer header 的請求

GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

只要指定了這種方案,資源伺服器就會嘗試根據 Bearer 令牌規範處理請求。

給定一個格式良好的 JWT,資源伺服器會

  1. 根據啟動期間從 jwks_url 端點獲取並與 JWT header 匹配的公鑰驗證其簽名。

  2. 驗證 JWT 的 expnbf 時間戳以及 JWT 的 iss 宣告。

  3. 將每個 scope 對映到帶有字首 SCOPE_ 的 authority。

隨著授權伺服器提供新金鑰,Spring Security 會自動輪換用於驗證 JWT 令牌的金鑰。

預設情況下,生成的 Authentication#getPrincipal 是一個 Spring Security Jwt 物件,如果存在,Authentication#getName 會對映到 JWT 的 sub 屬性。

從這裡,你可以考慮跳轉到

直接指定授權伺服器 JWK Set Uri

如果授權伺服器不支援任何配置端點,或者資源伺服器必須能夠獨立於授權伺服器啟動,你也可以提供 jwk-set-uri

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          jwk-set-uri: https://idp.example.com/.well-known/jwks.json

JWK Set uri 不是標準化的,但通常可以在授權伺服器的文件中找到它。

因此,資源伺服器在啟動時不會 ping 授權伺服器。我們仍然指定 issuer-uri,這樣資源伺服器仍然會驗證傳入 JWT 中的 iss 宣告。

你可以直接在 DSL 上提供此屬性。

覆蓋或替換 Boot 自動配置

Spring Boot 為資源伺服器生成兩個 @Bean 物件。

第一個 bean 是一個 SecurityWebFilterChain,它將應用程式配置為資源伺服器。當包含 spring-security-oauth2-jose 時,這個 SecurityWebFilterChain 看起來像

資源伺服器 SecurityWebFilterChain
  • Java

  • Kotlin

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(OAuth2ResourceServerSpec::jwt)
	return http.build();
}
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt { }
        }
    }
}

如果應用程式沒有暴露 SecurityWebFilterChain bean,Spring Boot 會暴露預設的那個(如前所示)。

要替換它,在應用程式中暴露 @Bean

替換 SecurityWebFilterChain
  • Java

  • Kotlin

import static org.springframework.security.oauth2.core.authorization.OAuth2ReactiveAuthorizationManagers.hasScope;

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.pathMatchers("/message/**").access(hasScope("message:read"))
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(oauth2 -> oauth2
			.jwt(withDefaults())
		);
	return http.build();
}
import org.springframework.security.oauth2.core.authorization.OAuth2ReactiveAuthorizationManagers.hasScope

@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize("/message/**", hasScope("message:read"))
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt { }
        }
    }
}

前面的配置要求任何以 /messages/ 開頭的 URL 都需要 message:read 範圍。

oauth2ResourceServer DSL 中的方法也會覆蓋或替換自動配置。

例如,Spring Boot 建立的第二個 @Bean 是一個 ReactiveJwtDecoder,它將 String 令牌解碼為已驗證的 Jwt 例項

ReactiveJwtDecoder
  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return ReactiveJwtDecoders.fromIssuerLocation(issuerUri);
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return ReactiveJwtDecoders.fromIssuerLocation(issuerUri)
}

呼叫 ReactiveJwtDecoders#fromIssuerLocation 會呼叫 Provider Configuration 或 Authorization Server Metadata 端點來推導 JWK Set URI。如果應用程式沒有暴露 ReactiveJwtDecoder bean,Spring Boot 會暴露上面預設的那個。

它的配置可以透過使用 jwkSetUri() 來覆蓋,或者透過使用 decoder() 來替換。

使用 jwkSetUri()

你可以將授權伺服器的 JWK Set URI 配置為屬性 或在 DSL 中提供

  • Java

  • Kotlin

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(oauth2 -> oauth2
			.jwt(jwt -> jwt
				.jwkSetUri("https://idp.example.com/.well-known/jwks.json")
			)
		);
	return http.build();
}
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt {
                jwkSetUri = "https://idp.example.com/.well-known/jwks.json"
            }
        }
    }
}

使用 jwkSetUri() 優先於任何配置屬性。

使用 decoder()

decoder()jwkSetUri() 更強大,因為它完全替換了 Spring Boot 對 JwtDecoder 的任何自動配置

  • Java

  • Kotlin

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(oauth2 -> oauth2
			.jwt(jwt -> jwt
				.decoder(myCustomDecoder())
			)
		);
    return http.build();
}
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt {
                jwtDecoder = myCustomDecoder()
            }
        }
    }
}

當你需要更深入的配置(例如驗證)時,這非常有用。

暴露 ReactiveJwtDecoder@Bean

或者,暴露 ReactiveJwtDecoder@Bean 與使用 decoder() 效果相同:你可以像這樣用 jwkSetUri 構建一個

  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
}

或者你可以使用 issuer,讓 NimbusReactiveJwtDecoder 在呼叫 build() 時查詢 jwkSetUri,如下所示

  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build()
}

或者,如果預設設定適合你,你也可以使用 JwtDecoders,它除了配置解碼器的驗證器外,還執行上述操作

  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return ReactiveJwtDecoders.fromIssuerLocation(issuer);
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return ReactiveJwtDecoders.fromIssuerLocation(issuer)
}

配置可信演算法

預設情況下,NimbusReactiveJwtDecoder,因此資源伺服器,只信任和驗證使用 RS256 的令牌。

你可以使用 Spring Boot 或透過使用 NimbusJwtDecoder 構建器來自定義此行為。

使用 Spring Boot 自定義可信演算法

設定演算法最簡單的方法是將其作為屬性

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jws-algorithms: RS512
          jwk-set-uri: https://idp.example.org/.well-known/jwks.json

透過使用構建器自定義可信演算法

然而,為了獲得更大的能力,我們可以使用 NimbusReactiveJwtDecoder 自帶的構建器

  • Java

  • Kotlin

@Bean
ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).build()
}

多次呼叫 jwsAlgorithm 會配置 NimbusReactiveJwtDecoder 信任多個演算法

  • Java

  • Kotlin

@Bean
ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build()
}

或者,你可以呼叫 jwsAlgorithms

  • Java

  • Kotlin

@Bean
ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.jwkSetUri)
            .jwsAlgorithms(algorithms -> {
                    algorithms.add(RS512);
                    algorithms.add(ES512);
            }).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withIssuerLocation(this.jwkSetUri)
            .jwsAlgorithms {
                it.add(RS512)
                it.add(ES512)
            }
            .build()
}

信任單個非對稱金鑰

比使用 JWK Set 端點支援資源伺服器更簡單的方法是硬編碼一個 RSA 公鑰。公鑰可以使用 Spring Boot 或透過使用構建器來提供。

透過 Spring Boot

你可以使用 Spring Boot 指定一個金鑰

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:my-key.pub

或者,為了實現更復雜的查詢,你可以後處理 RsaKeyConversionServicePostProcessor

BeanFactoryPostProcessor
  • Java

  • Kotlin

@Bean
BeanFactoryPostProcessor conversionServiceCustomizer() {
    return beanFactory ->
        beanFactory.getBean(RsaKeyConversionServicePostProcessor.class)
                .setResourceLoader(new CustomResourceLoader());
}
@Bean
fun conversionServiceCustomizer(): BeanFactoryPostProcessor {
    return BeanFactoryPostProcessor { beanFactory: ConfigurableListableBeanFactory ->
        beanFactory.getBean<RsaKeyConversionServicePostProcessor>()
                .setResourceLoader(CustomResourceLoader())
    }
}

指定金鑰的位置

key.location: hfds://my-key.pub

然後自動裝配該值

  • Java

  • Kotlin

@Value("${key.location}")
RSAPublicKey key;
@Value("\${key.location}")
val key: RSAPublicKey? = null

使用構建器

要直接注入一個 RSAPublicKey,請使用適當的 NimbusReactiveJwtDecoder 構建器

  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withPublicKey(this.key).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withPublicKey(key).build()
}

信任單個對稱金鑰

你也可以使用單個對稱金鑰。你可以載入你的 SecretKey 並使用適當的 NimbusReactiveJwtDecoder 構建器

  • Java

  • Kotlin

@Bean
public ReactiveJwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withSecretKey(this.key).build();
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    return NimbusReactiveJwtDecoder.withSecretKey(this.key).build()
}

配置授權

從 OAuth 2.0 授權伺服器頒發的 JWT 通常具有 scopescp 屬性,指示已授予的範圍(或許可權)—— 例如

{ ..., "scope" : "messages contacts"}

在這種情況下,資源伺服器會嘗試將這些 scope 強制轉換為授予許可權列表,並在每個 scope 前加上字串 SCOPE_

這意味著,要使用從 JWT 派生的 scope 保護端點或方法,相應的表示式應包含此字首

  • Java

  • Kotlin

import static org.springframework.security.oauth2.core.authorization.OAuth2ReactiveAuthorizationManagers.hasScope;

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.mvcMatchers("/contacts/**").access(hasScope("contacts"))
			.mvcMatchers("/messages/**").access(hasScope("messages"))
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(OAuth2ResourceServerSpec::jwt);
    return http.build();
}
import org.springframework.security.oauth2.core.authorization.OAuth2ReactiveAuthorizationManagers.hasScope

@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize("/contacts/**", hasScope("contacts"))
            authorize("/messages/**", hasScope("messages"))
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt { }
        }
    }
}

你可以使用方法安全執行類似操作

  • Java

  • Kotlin

@PreAuthorize("hasAuthority('SCOPE_messages')")
public Flux<Message> getMessages(...) {}
@PreAuthorize("hasAuthority('SCOPE_messages')")
fun getMessages(): Flux<Message> { }

手動提取許可權

然而,在某些情況下,此預設設定不足。例如,一些授權伺服器不使用 scope 屬性。相反,它們有自己的自定義屬性。在其他時候,資源伺服器可能需要將屬性或屬性組合適配到內部許可權。

為此,DSL 暴露了 jwtAuthenticationConverter()

  • Java

  • Kotlin

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
	http
		.authorizeExchange(exchanges -> exchanges
			.anyExchange().authenticated()
		)
		.oauth2ResourceServer(oauth2 -> oauth2
			.jwt(jwt -> jwt
				.jwtAuthenticationConverter(grantedAuthoritiesExtractor())
			)
		);
	return http.build();
}

Converter<Jwt, Mono<AbstractAuthenticationToken>> grantedAuthoritiesExtractor() {
    JwtAuthenticationConverter jwtAuthenticationConverter =
            new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter
            (new GrantedAuthoritiesExtractor());
    return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        authorizeExchange {
            authorize(anyExchange, authenticated)
        }
        oauth2ResourceServer {
            jwt {
                jwtAuthenticationConverter = grantedAuthoritiesExtractor()
            }
        }
    }
}

fun grantedAuthoritiesExtractor(): Converter<Jwt, Mono<AbstractAuthenticationToken>> {
    val jwtAuthenticationConverter = JwtAuthenticationConverter()
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(GrantedAuthoritiesExtractor())
    return ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter)
}

jwtAuthenticationConverter() 負責將 Jwt 轉換為 Authentication。作為其配置的一部分,我們可以提供一個輔助轉換器,將 Jwt 轉換為授予許可權的 Collection

該最終轉換器可能類似於以下 GrantedAuthoritiesExtractor

  • Java

  • Kotlin

static class GrantedAuthoritiesExtractor
        implements Converter<Jwt, Collection<GrantedAuthority>> {

    public Collection<GrantedAuthority> convert(Jwt jwt) {
        Collection<?> authorities = (Collection<?>)
                jwt.getClaims().getOrDefault("mycustomclaim", Collections.emptyList());

        return authorities.stream()
                .map(Object::toString)
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
}
internal class GrantedAuthoritiesExtractor : Converter<Jwt, Collection<GrantedAuthority>> {
    override fun convert(jwt: Jwt): Collection<GrantedAuthority> {
        val authorities: List<Any> = jwt.claims
                .getOrDefault("mycustomclaim", emptyList<Any>()) as List<Any>
        return authorities
                .map { it.toString() }
                .map { SimpleGrantedAuthority(it) }
    }
}

為了更大的靈活性,DSL 支援用任何實現 Converter<Jwt, Mono<AbstractAuthenticationToken>> 的類完全替換轉換器

  • Java

  • Kotlin

static class CustomAuthenticationConverter implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {
    public AbstractAuthenticationToken convert(Jwt jwt) {
        return Mono.just(jwt).map(this::doConversion);
    }
}
internal class CustomAuthenticationConverter : Converter<Jwt, Mono<AbstractAuthenticationToken>> {
    override fun convert(jwt: Jwt): Mono<AbstractAuthenticationToken> {
        return Mono.just(jwt).map(this::doConversion)
    }
}

配置驗證

使用最少的 Spring Boot 配置,指示授權伺服器的 issuer URI,資源伺服器預設會驗證 iss 宣告以及 expnbf 時間戳宣告。

在需要自定義驗證需求的情況下,資源伺服器提供了兩個標準驗證器,並且還接受自定義的 OAuth2TokenValidator 例項。

自定義時間戳驗證

JWT 例項通常有一個有效期視窗,視窗的開始由 nbf 宣告指示,結束由 exp 宣告指示。

然而,每個伺服器都可能出現時鐘漂移,這可能導致令牌在一個伺服器上看起來已過期,但在另一個伺服器上未過期。隨著分散式系統中協作伺服器數量的增加,這可能會導致一些實現上的問題。

資源伺服器使用 JwtTimestampValidator 來驗證令牌的有效期視窗,你可以使用 clockSkew 配置它以緩解時鐘漂移問題

  • Java

  • Kotlin

@Bean
ReactiveJwtDecoder jwtDecoder() {
     NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder)
             ReactiveJwtDecoders.fromIssuerLocation(issuerUri);

     OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
            new JwtTimestampValidator(Duration.ofSeconds(60)),
            new IssuerValidator(issuerUri));

     jwtDecoder.setJwtValidator(withClockSkew);

     return jwtDecoder;
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder
    val withClockSkew: OAuth2TokenValidator<Jwt> = DelegatingOAuth2TokenValidator(
            JwtTimestampValidator(Duration.ofSeconds(60)),
            JwtIssuerValidator(issuerUri))
    jwtDecoder.setJwtValidator(withClockSkew)
    return jwtDecoder
}

預設情況下,資源伺服器配置了 60 秒的時鐘偏移。

配置自定義驗證器

你可以使用 OAuth2TokenValidator API 新增對 aud 宣告的檢查

  • Java

  • Kotlin

public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
    OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null);

    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        if (jwt.getAudience().contains("messaging")) {
            return OAuth2TokenValidatorResult.success();
        } else {
            return OAuth2TokenValidatorResult.failure(error);
        }
    }
}
class AudienceValidator : OAuth2TokenValidator<Jwt> {
    var error: OAuth2Error = OAuth2Error("invalid_token", "The required audience is missing", null)
    override fun validate(jwt: Jwt): OAuth2TokenValidatorResult {
        return if (jwt.audience.contains("messaging")) {
            OAuth2TokenValidatorResult.success()
        } else {
            OAuth2TokenValidatorResult.failure(error)
        }
    }
}

然後,要將其新增到資源伺服器中,你可以指定 ReactiveJwtDecoder 例項

  • Java

  • Kotlin

@Bean
ReactiveJwtDecoder jwtDecoder() {
    NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder)
            ReactiveJwtDecoders.fromIssuerLocation(issuerUri);

    OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator();
    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
    OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

    jwtDecoder.setJwtValidator(withAudience);

    return jwtDecoder;
}
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
    val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder
    val audienceValidator: OAuth2TokenValidator<Jwt> = AudienceValidator()
    val withIssuer: OAuth2TokenValidator<Jwt> = JwtValidators.createDefaultWithIssuer(issuerUri)
    val withAudience: OAuth2TokenValidator<Jwt> = DelegatingOAuth2TokenValidator(withIssuer, audienceValidator)
    jwtDecoder.setJwtValidator(withAudience)
    return jwtDecoder
}