SpringSecurity中文文档(Servlet OAuth 2.0 Resource Server)

OAuth 2.0 Resource Server

Spring Security 通过使用两种形式的 OAuth 2.0无记名令牌来支持对端点的保护:

  • JWT
  • Opaque Tokens

在应用程序将其权限管理委托给授权服务器(例如 Okta 或 Ping Identity)的情况下,这非常方便。资源服务器可以查询此授权服务器来对请求进行授权。

本节详细介绍 Spring Security 如何提供对 OAuth 2.0 Bearer Token 的支持。

SpringSecuritySamples 存储库中提供了用于 JWT 和 Opaque 令牌的工作示例。

现在我们可以考虑在 Spring 安全性中承载令牌身份验证是如何工作的。首先,我们看到,与基本身份验证一样,WWW-Authenticate 头被发送回未经身份验证的客户端:

上图是基于我们的 SecurityFilterChain 图构建的。

  1. 首先,用户向未授权的/私有资源发出未经身份验证的请求。
  2. Spring Security 的 AuthorizationFilter 指示通过引发 AccessDeniedException 拒绝未经身份验证的请求。
  3. 由于用户没有经过身份验证,因此 ExceptionTransationFilter 将启动"启动身份验证"。配置的 AuthenticationEntryPoint 是 BearerTokenAuthenticationEntryPoint 的一个实例,它发送一个 WWW-Authenticate 头。RequestCache 通常是不保存请求的 NullRequestCache,因为客户端能够重播它最初请求的请求。

当客户端收到 WWW-Authenticate: Bearer 头文件时,它知道应该使用持有者令牌重试。下图显示了正在处理的持有者令牌的流程:

该图基于我们的 SecurityFilterChain 图构建。

  1. 当用户提交其持有者令牌时,BearerTokenAuthenticationFilter 通过从 HttpServletRequest 中提取令牌创建一个 BearerTokenAuthenticationToken,这是一种身份验证类型。
  2. 接下来,HttpServletRequest 被传递给 AuthenticationManagerResolver,它选择 AuthenticationManager。BearerTokenAuthenticationToken 被传递到 AuthenticationManager 以进行身份验证。AuthenticationManager 的详细信息取决于您是配置为 JWT 还是不透明令牌。
  3. 如果身份验证失败,则为"失败"
    • SecurityContextHolder 被清除。
    • 将调用 AuthenticationEntryPoint 来触发要再次发送的 WWW-Authenticate 标头。
  4. 如果身份验证成功,则为"成功"。
    • 身份验证在 SecurityContextHolder 上设置。
    • BearerTokenAuthenticationFilter 调用 FilterChain.doFilter (request,response)来继续其余的应用程序逻辑。

OAuth 2.0 Resource Server JWT

Minimal Dependencies for JWT

大多数资源服务器支持被收集到 spring-security-oauth2-Resource-Server 中。然而,对解码和验证 JWT 的支持是 spring-security-oauth2-jose,这意味着为了拥有一个支持 JWT 编码的 Bearer Token 的工作资源服务器,这两者都是必需的。

Minimal Configuration for JWTs

在使用 SpringBoot 时,将应用程序配置为资源服务器包括两个基本步骤。首先,包含所需的依赖项,其次,指示授权服务器的位置。

Specifying the Authorization Server

在 Spring Boot 应用程序中,要指定使用哪个授权服务器,只需执行以下操作:

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer

其中的 idp.example.com/issuer 是授权服务器将发出的 jWT 令牌的 iss 声明中包含的值。资源服务器将使用此属性进一步自配置、发现授权服务器的公钥并随后验证传入的 JWT。

要使用 signer-uri 属性,还必须确保 idp.example.com/issuer/.well-known/openid-configuration、 idp.example.com/.well-known/openid-configuration/issueridp.example.com/.well-known/oauth-authorization-server/issuer 之一是授权服务器支持的端点。此端点称为提供程序配置端点或授权服务器元数据端点。

Startup Expectations

使用此属性和这些依赖项时,资源服务器将自动配置自身以验证 JWT 编码的承载令牌。

它通过一个确定性的启动过程来实现这一点:

  • 为 jwks _ url 属性查询提供程序配置或授权服务器元数据终结点
  • 查询 jwks _ url 端点以获得支持的算法
  • 将验证策略配置为查询找到的算法的有效公钥的 jwks _ url
  • 配置验证策略,以验证针对 idp.example.com 的每个 JWT 请求。

这个过程的结果是,授权服务器必须启动并接收请求,以便 Resource Server 成功启动。

如果在资源服务器查询授权服务器时,授权服务器关闭(给定适当的超时) ,则启动将失败。

Runtime Expectations

一旦应用程序启动,资源服务器将尝试处理任何包含 Authorization: Bearer 头的请求:

Bearer header:

java 复制代码
GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

只要指定了此方案,资源服务器就会尝试根据 Bearer Token 规范处理请求。

给定一个结构良好的 JWT,Resource Server 将:

  • 在启动期间根据从 jwks _ url 端点获得的公钥验证其签名,并与 JWT 进行匹配
  • 验证 JWT 的 exp 和 nbf 时间戳以及 JWT 的 iss 声明,并且
  • 将每个范围映射到前缀 SCOPE _ 的授权。

当授权服务器提供可用的新密钥时,SpringSecurity 将自动旋转用于验证 JWT 的密钥。

默认情况下,结果 Authentication # getPrime 是 Spring Security JWT 对象,Authentication # getName 映射到 JWT 的子属性(如果有的话)。

从这里开始,考虑跳到:

How JWT Authentication Works

接下来,让我们看看 Spring Security 在基于 servlet 的应用程序中支持 JWT 验证所使用的体系结构组件,就像我们刚才看到的那样。

JwtAuthenticationProvider 是一个 AuthenticationProvider 实现,它利用 JwtDecder 和 JwtAuthenticationConverter 对 JWT 进行身份验证。

让我们看看 JwtAuthenticationProvider 在 Spring Security 中是如何工作的。该图解释了"阅读无记名令牌"中的数字中的 AuthenticationManager 如何工作的详细信息。


*Figure 1.* `JwtAuthenticationProvider` *Usage*

  1. 通过读取 Bearer 令牌的身份验证过滤器将 BearerTokenAuthenticationToken 传递给 AuthenticationManager,这是由 ProviderManager 实现的。
  2. ProviderManager 配置为使用类型为 JwtAuthenticationProvider 的 AuthenticationProvider。
  3. JwtAuthenticationProvider 使用 JwtDecder 对 Jwt 进行解码、验证和验证。
  4. 然后,JwtAuthenticationProvider 使用 JwtAuthenticationConverter 将 Jwt 转换为授予权限的集合。
  5. 当身份验证成功时,返回的身份验证类型为 JwtAuthenticationToken,其主体为配置的 JwtDecder 返回的 Jwt。最终,返回的 JwtAuthenticationToken 将由身份验证筛选器在 SecurityContextHolder 上设置。

Specifying the Authorization Server JWK Set Uri Directly

如果授权服务器不支持任何配置端点,或者资源服务器必须能够独立于授权服务器启动,那么也可以提供 jwk-set-uri:

java 复制代码
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 授权服务器。我们仍然指定发布者-uri,这样 Resource Server 仍然会在传入的 JWT 上验证 iss 声明。

此属性也可以直接在 DSL 上提供。

Supplying Audiences

正如已经看到的那样,signer-uri 属性验证 iss 声明; 这就是发送 JWT 的人。

Boot 还具有验证 aud 声明的受众属性; 这就是发送 JWT 的对象。

资源服务器的受众可以这样表示:

java 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          audiences: https://my-resource-server.example.com

如果需要,还可以通过编程方式添加 aud 验证。

其结果是,如果 JWT 的 iss 声明不是 idp.example.com,其 aud 声明也不包含 my-resource-server.example.com ,那么验证就会失败。

Default JWT Configuration

java 复制代码
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

如果应用程序没有公开 SecurityFilterChain bean,那么 SpringBoot 将公开上面的默认 bean。

替换它就像在应用程序中公开 bean 一样简单:

Custom JWT Configuration

java 复制代码
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/messages/**").access(hasScope("message:read"))
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(myConverter())
                )
            );
        return http.build();
    }
}

以上要求消息的作用域: 读取以/message/开头的任何 URL。

Oauth2ResourceServerDSL 上的方法也将覆盖或替换自动配置。

例如,@Bean Spring Boot 创建的第二个是 JwtDecder,它将字符串标记解码为 Jwt 的有效实例:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    return JwtDecoders.fromIssuerLocation(issuerUri);
}

从 IssuerLocation 调用 JwtDecoders # 调用提供者配置或授权服务器元数据端点以派生 JWK Set Uri。

如果应用程序没有公开 JwtDecoderbean,那么 SpringBoot 将公开上面的默认 bean。

可以使用 jwkSetUri ()覆盖它的配置,也可以使用 decder ()替换它。

或者,如果您根本不使用 Spring Boot,那么可以用 XML 指定这两个组件------筛选器链和 JwtDecder。

过滤器链是这样指定的:

Default JWT Configuration

java 复制代码
<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <jwt decoder-ref="jwtDecoder"/>
    </oauth2-resource-server>
</http>

JwtDecder 是这样的:

JWT Decoder

java 复制代码
<bean id="jwtDecoder"
        class="org.springframework.security.oauth2.jwt.JwtDecoders"
        factory-method="fromIssuerLocation">
    <constructor-arg value="${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}"/>
</bean>

Using jwkSetUri()

授权服务器的 JWK Set Uri 可以配置为配置属性,也可以在 DSL 中提供:

java 复制代码
@Configuration
@EnableWebSecurity
public class DirectlyConfiguredJwkSetUri {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwkSetUri("https://idp.example.com/.well-known/jwks.json")
                )
            );
        return http.build();
    }
}

使用 jwkSetUri ()优先于任何配置属性。

Using decoder()

比 jwkSetUri ()更强大的是 decoder() ,它将完全取代 JwtDecder 的任何 Boot 自动配置:

JWT Decoder Configuration

java 复制代码
@Configuration
@EnableWebSecurity
public class DirectlyConfiguredJwtDecoder {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(myCustomDecoder())
                )
            );
        return http.build();
    }
}

当需要更深层次的配置(如验证、映射或请求超时)时,这非常方便。

Exposing a JwtDecoder @Bean

或者,暴露一个 JwtDecder@Bean 的效果和视频解码器()一样,你可以像这样用 jwkSetUri 来构造一个:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}

或者您可以使用发行者并让 NimbusJwtDecder 在调用 build ()时查找 jwkSetUri,如下所示:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withIssuerLocation(issuer).build();
}

或者,如果默认值适用于您,您也可以使用 JwtDecoders,它除了配置解码器的验证器之外,还可以执行上述操作:

java 复制代码
@Bean
public JwtDecoders jwtDecoder() {
    return JwtDecoders.fromIssuerLocation(issuer);
}

Configuring Trusted Algorithms

默认情况下,NimbusJwtDecder 以及资源服务器将只使用 RS256信任和验证令牌。

您可以通过 SpringBoot、 NimbusJwtDecder 构建器或从 JWKSet 响应来自定义此选项。

Via Spring Boot

设置算法的最简单方法是将其作为属性:

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

Using a Builder

不过,为了获得更强大的功能,我们可以使用附带 NimbusJwtDecder 的构建器:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).build();
}

多次调用 jws 算法会将 NimbusJwtDecder 配置为信任多个算法,如下所示:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build();
}

或者,你可以称之为 jws 算法:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withIssuerLocation(this.issuer)
            .jwsAlgorithms(algorithms -> {
                    algorithms.add(RS512);
                    algorithms.add(ES512);
            }).build();
}

From JWK Set response

由于 Spring Security 的 JWT 支持是基于 Nimbus 的,所以你也可以使用它所有的优秀特性。

例如,Nimbus 有一个 JWSKeySelector 实现,它将基于 JWKSetURI 响应选择一组算法。您可以使用它来生成 NimbusJwtDecder,如下所示:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    // makes a request to the JWK Set endpoint
    JWSKeySelector<SecurityContext> jwsKeySelector =
            JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(this.jwkSetUrl);

    DefaultJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor<>();
    jwtProcessor.setJWSKeySelector(jwsKeySelector);

    return new NimbusJwtDecoder(jwtProcessor);
}

Trusting a Single Asymmetric Key

比使用 JWK 集端点支持资源服务器更简单的方法是硬编码 RSA 公钥。公钥可以通过 Spring 引导或使用 Builder 提供。

Via Spring Boot

通过 Spring Boot 指定密钥非常简单。可以这样指定密钥的位置:

java 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:my-key.pub

或者,为了支持更复杂的查找,您可以对 RsaKeyConversonServicePostProcessor 进行后处理:

java 复制代码
@Bean
BeanFactoryPostProcessor conversionServiceCustomizer() {
    return beanFactory ->
        beanFactory.getBean(RsaKeyConversionServicePostProcessor.class)
                .setResourceLoader(new CustomResourceLoader());
}

指定密钥的位置:

yaml 复制代码
key.location: hfds://my-key.pub

然后自动获取value:

java 复制代码
@Value("${key.location}")
RSAPublicKey key;

Using a Builder

要直接连接 RSAPublicKey,只需使用适当的 NimbusJwtDecder 构建器,如下所示:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(this.key).build();
}

Trusting a Single Symmetric Key

使用单个对称密钥也很简单。你可以简单的加载你的 Secretariat Key 并使用适当的 NimbusJwtDecder 构建器,如下所示:

java 复制代码
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withSecretKey(this.key).build();
}

Configuring Authorization

从 OAuth 2.0 Authorization Server 发布的 JWT 通常会有一个 scope 或 scp 属性,指示它被授予的范围(或权限) ,例如:

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

在这种情况下,资源服务器将尝试将这些作用域强制转换为授权的权限列表,并在每个作用域前面加上字符串" SCOPE _"。

这意味着,为了保护具有从 JWT 派生的作用域的端点或方法,相应的表达式应该包含以下前缀:

java 复制代码
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration
@EnableWebSecurity
public class DirectlyConfiguredJwkSetUri {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/contacts/**").access(hasScope("contacts"))
                .requestMatchers("/messages/**").access(hasScope("messages"))
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }
}

或者与方法安全类似:

java 复制代码
@PreAuthorize("hasAuthority('SCOPE_messages')")
public List<Message> getMessages(...) {}

Extracting Authorities Manually

但是,在许多情况下,这种默认值是不够的。例如,有些授权服务器不使用 scope 属性,而是具有自己的自定义属性。或者,在其他时候,资源服务器可能需要将属性或属性的组合调整为内部化的权限。

为此,Spring Security 附带了 JwtAuthenticationConverter,它负责将 Jwt 转换为身份验证。默认情况下,SpringSecurity 将使用默认的 JwtAuthenticationConverter 实例连接 JwtAuthenticationProvider。

作为配置 JwtAuthenticationConverter 的一部分,您可以提供一个从 Jwt 到授权权限集合的辅助转换器。

假设您的授权服务器在一个称为"授权"的自定义声明中与授权机构进行通信。在这种情况下,可以配置 JwtAuthenticationConverter 应该检查的声明,如下所示:

Authorities Claim Configuration

java 复制代码
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
}

您还可以将权限前缀配置为不同的。您可以将其更改为 ROLE _,而不是在每个权限前面加上 SCOPE _,如下所示:

Authorities Prefix Configuration

java 复制代码
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
}

或者,可以通过调用 JwtGrantedAuthortiesConverter # setAuthorityPrefix ("")完全删除前缀。

为了获得更大的灵活性,DSL 支持用任何实现 Converter < Jwt,AbstractAuthenticationToken > 的类完全替换转换器:

java 复制代码
static class CustomAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    public AbstractAuthenticationToken convert(Jwt jwt) {
        return new CustomAuthenticationToken(jwt);
    }
}

// ...

@Configuration
@EnableWebSecurity
public class CustomAuthenticationConverterConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(new CustomAuthenticationConverter())
                )
            );
        return http.build();
    }
}

Configuring Validation

使用最小的 Spring Boot 配置(指示授权服务器的发行者 uri) ,Resource Server 将默认验证 iss 声明以及 exp 和 nbf 时间戳声明。

在需要自定义验证的情况下,Resource Server 提供了两个标准验证器,并且还接受自定义 OAuth2TokenValidator 实例。

Customizing Timestamp Validation

JWT 通常有一个有效的窗口,窗口的开始在 nbf 声明中指示,结束在 exp 声明中指示。

但是,每个服务器都可能出现时钟漂移,这可能导致令牌在一个服务器上看起来过期,而在另一个服务器上却没有过期。随着分布式系统中协作服务器数量的增加,这可能会引起一些实现的烦恼。

Resource Server 使用 JwtTimestampValidator 来验证令牌的有效性窗口,并且可以配置为 lockSkew 来缓解上述问题:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
     NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
             JwtDecoders.fromIssuerLocation(issuerUri);

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

     jwtDecoder.setJwtValidator(withClockSkew);

     return jwtDecoder;
}

默认情况下,资源服务器配置的时钟偏差为60秒。

Configuring a Custom Validator

使用 OAuth2TokenValidator API 为 aud 声明添加检查非常简单:

java 复制代码
OAuth2TokenValidator<Jwt> audienceValidator() {
    return new JwtClaimValidator<List<String>>(AUD, aud -> aud.contains("messaging"));
}

或者,为了获得更多的控制,您可以实现您自己的 OAuth2TokenValidator:

java 复制代码
static class AudienceValidator implements OAuth2TokenValidator<Jwt> {
    OAuth2Error error = new OAuth2Error("custom_code", "Custom error message", null);

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        if (jwt.getAudience().contains("messaging")) {
            return OAuth2TokenValidatorResult.success();
        } else {
            return OAuth2TokenValidatorResult.failure(error);
        }
    }
}

// ...

OAuth2TokenValidator<Jwt> audienceValidator() {
    return new AudienceValidator();
}

然后,要添加到资源服务器中,就需要指定 JwtDeceder 实例:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
        JwtDecoders.fromIssuerLocation(issuerUri);

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

    jwtDecoder.setJwtValidator(withAudience);

    return jwtDecoder;
}

如前所述,您可以在 Boot 中配置 aud 验证。

Configuring Claim Set Mapping

Spring Security 使用 Nimbus 库解析 JWT 并验证它们的签名。因此,Spring Security 取决于 Nimbus 对每个字段值的解释,以及如何将其强制转换为 Java 类型。

例如,因为 Nimbus 仍然与 Java7兼容,所以它不使用 Instant 来表示时间戳字段。

而且完全可以使用不同的库或者用于 JWT 处理,JWT 处理可能会做出需要调整的强制决策。

或者,非常简单,出于特定于域的原因,资源服务器可能希望从 JWT 中添加或删除声明。

出于这些目的,资源服务器支持将 JWT 声明集与 MappedJwtClaimSetConverter 映射。

Customizing the Conversion of a Single Claim

默认情况下,MappedJwtClaySetConverter 将尝试将claims 强制为以下类型:

Claim Java Type
aud Collection<String>
exp Instant
iat Instant
iss String
jti String
nbf Instant
sub String

单个claim的转换策略可以使用 MappedJwtClamSetConverter.withDefault 配置:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();

    MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter
            .withDefaults(Collections.singletonMap("sub", this::lookupUserIdBySub));
    jwtDecoder.setClaimSetConverter(converter);

    return jwtDecoder;
}

这将保留所有的默认值,除了它将覆盖 sub 的默认claim转换器。

Adding a Claim

还可以使用 MappedJwt柔性声明设置转换器添加自定义声明,例如,以适应现有系统:

java 复制代码
MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("custom", custom -> "value"));

Removing a Claim

java 复制代码
MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("legacyclaim", legacy -> null));

Renaming a Claim

在更复杂的场景中,比如一次查询多个声明或者重命名一个声明,Resource Server 接受任何实现 Converter < Map < String,Object > ,Map < String,Object > 的类:

java 复制代码
public class UsernameSubClaimAdapter implements Converter<Map<String, Object>, Map<String, Object>> {
    private final MappedJwtClaimSetConverter delegate =
            MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());

    public Map<String, Object> convert(Map<String, Object> claims) {
        Map<String, Object> convertedClaims = this.delegate.convert(claims);

        String username = (String) convertedClaims.get("user_name");
        convertedClaims.put("sub", username);

        return convertedClaims;
    }
}

然后,实例可以像正常情况一样提供:

java 复制代码
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
    jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
    return jwtDecoder;
}

Configuring Timeouts

默认情况下,资源服务器使用30秒的连接和套接字超时来协调授权服务器。

在某些情况下,这可能过于短暂。此外,它没有考虑更复杂的模式,如后退和发现。

为了调整 Resource Server 连接到授权服务器的方式,NimbusJwtDeceder 接受 RestOperations 的一个实例:

java 复制代码
@Bean
public JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
    RestOperations rest = builder
            .setConnectTimeout(Duration.ofSeconds(60))
            .setReadTimeout(Duration.ofSeconds(60))
            .build();

    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(rest).build();
    return jwtDecoder;
}

另外,在默认情况下,资源服务器将授权服务器的 JWK 集缓存在内存中5分钟,您可能需要对此进行调整。此外,它没有考虑更复杂的缓存模式,如驱逐或使用共享缓存。

java 复制代码
@Bean
public JwtDecoder jwtDecoder(CacheManager cacheManager) {
    return NimbusJwtDecoder.withIssuerLocation(issuer)
            .cache(cacheManager.getCache("jwks"))
            .build();
}

当给定一个 Cache 时,资源服务器将使用 JWK Set Uri 作为键,并使用 JWK Set JSON 作为值。

Spring 不是一个缓存提供程序,所以您需要确保包含适当的依赖项,比如 Spring-boot-starter-cache 和您喜欢的缓存提供程序。

无论是套接字超时还是缓存超时,您都可能希望直接使用 Nimbus。为此,请记住 NimbusJwtDecder 附带了一个使用 Nimbus 的 JWTProcessor 的构造函数。

OAuth 2.0 Resource Server Opaque Token

Minimal Dependencies for Introspection

如 JWT 的最小依赖项中所述,大多数资源服务器支持都是在 spring-security-oauth2-Resource-Server 中收集的。但是,除非提供了自定义 OpaqueTokenintrspector,否则资源服务器将回退到 NimbusOpaqueTokenintrspector。这意味着 Spring-security-oauth2-Resource-Server 和 oauth2-oidc-sdk 都是必需的,这样才能有一个支持不透明承载令牌的工作最小资源服务器。请参考 spring-security-oauth2-resource-server 以确定 oauth2-oidc-sdk 的正确版本。

Minimal Configuration for Introspection

通常,可以通过由授权服务器承载的 OAuth 2.0内省端点来验证不透明令牌。当需要撤销时,这很方便。

在使用 SpringBoot 时,将应用程序配置为使用内省的资源服务器包括两个基本步骤。首先,包括所需的依赖关系,其次,指出自省端点的详细信息。

Specifying the Authorization Server

要指定内省端点的位置,只需执行以下操作:

java 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: https://idp.example.com/introspect
          client-id: client
          client-secret: secret

其中 idp.example.com/introspect 是授权服务器托管的内省端点,而客户端-id 和客户端-secret 是访问该端点所需的凭据。

资源服务器将使用这些属性进一步自配置并随后验证传入的 JWT。

当使用自省时,授权服务器的话就是法律。如果授权服务器响应该令牌是有效的,那么它就是有效的。

Startup Expectations

使用此属性和这些依赖项时,资源服务器将自动配置自身以验证不透明承载令牌。

这个启动过程比 JWT 要简单得多,因为不需要发现端点,也不需要添加额外的验证规则。

Runtime Expectations

一旦应用程序启动,资源服务器将尝试处理任何包含 Authorization: Bearer 头的请求:

java 复制代码
GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

只要指定了此方案,资源服务器就会尝试根据 Bearer Token 规范处理请求。

给定一个不透明令牌,资源服务器将

  1. 使用提供的凭据和令牌查询提供的内省端点
  2. 检查{' active': true }属性的响应
  3. 将每个范围映射到前缀 SCOPE _ 的权限

默认情况下,结果 Authentication # getPrime 是一个 Spring Security OAuthenticatedPrime 对象,Authentication # getName 映射到令牌的子属性(如果有的话)。

从这里开始,你可能想跳到:

How Opaque Token Authentication Works

接下来,让我们看看 Spring Security 在基于 servlet 的应用程序中支持不透明令牌身份验证的体系结构组件,就像我们刚才看到的那样。

OpaqueTokenAuthenticationProvider 是一个 AuthenticationProvider 实现,它利用 OpaqueTokenintrspector 对不透明令牌进行身份验证。

让我们看看 OpaqueTokenAuthenticationProvider 在 Spring Security 中是如何工作的。该图解释了"阅读无记名令牌"中的数字中的 AuthenticationManager 如何工作的详细信息。


*Figure 1.* `OpaqueTokenAuthenticationProvider` *Usage*

  1. 通过读取 Bearer 令牌的身份验证过滤器将 BearerTokenAuthenticationToken 传递给 AuthenticationManager,这是由 ProviderManager 实现的。
  2. ProviderManager 配置为使用类型为 OpaqueTokenAuthenticationProvider 的 AuthenticationProvider
  3. OpaqueTokenAuthenticationProvider 内省不透明令牌,并使用 OpaqueTokenintrspector 添加授予的权限。当身份验证成功时,返回的 Authentication 类型为 BearerTokenAuthentication,并且具有一个主体,该主体是由配置的 OpaqueTokenintrspector 返回的 OAuth2Authenticated 主体。最终,返回的 BearerTokenAuthentication 将通过身份验证筛选器在 SecurityContextHolder 上设置。

Looking Up Attributes Post-Authentication

一旦对令牌进行了身份验证,就会在 SecurityContext 中设置 BearerTokenAuthentication 的实例。

这意味着在配置中使用@EnableWebMvc 时,它可以在@Controller 方法中使用:

java 复制代码
@GetMapping("/foo")
public String foo(BearerTokenAuthentication authentication) {
    return authentication.getTokenAttributes().get("sub") + " is the subject";
}

由于 BearerTokenAuthentication 持有一个 OAuth2Authenticated 主体,这也意味着它对控制器方法也是可用的:

java 复制代码
@GetMapping("/foo")
public String foo(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
    return principal.getAttribute("sub") + " is the subject";
}

Looking Up Attributes Via SpEL

当然,这也意味着可以通过 SpEL 访问属性。

例如,如果使用@EnableGlobalMethodSecurity 以便可以使用@PreAuthorize 注释,则可以:

java 复制代码
@PreAuthorize("principal?.attributes['sub'] == 'foo'")
public String forFoosEyesOnly() {
    return "foo";
}

Overriding or Replacing Boot Auto Configuration

SpringBoot 代表资源服务器生成了两个@Bean。

第一个是 SecurityFilterChain,它将应用程序配置为资源服务器。当使用不透明令牌时,这个 SecurityFilterChain 看起来像:

Default Opaque Token Configuration

java 复制代码
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
    return http.build();
}

如果应用程序没有公开 SecurityFilterChain bean,那么 SpringBoot 将公开上面的默认 bean。

替换它就像在应用程序中公开 bean 一样简单:

Custom Opaque Token Configuration

java 复制代码
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/messages/**").access(hasScope("message:read"))
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspector(myIntrospector())
                )
            );
        return http.build();
    }
}

以上要求消息的作用域: 读取以/message/开头的任何 URL。

Oauth2ResourceServerDSL 上的方法也将覆盖或替换自动配置。

例如,第二个@Bean Spring Boot 创建的是一个 OpaqueTokenintrspector,它将字符串标记解码为 OAuth2AuthenticatedPrime 的验证实例:

java 复制代码
@Bean
public OpaqueTokenIntrospector introspector() {
    return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

如果应用程序没有公开 OpaqueTokenintrspectorbean,那么 SpringBoot 将公开上面的默认 bean。

而且它的配置可以使用 intrspectionUri ()和 intrspectionClientCreentials ()覆盖,或者使用 intrspector ()替换。

如果应用程序不公开 OpaqueTokenAuthenticationConverterbean,那么 spring-security 将构建 BearerTokenAuthentication。

或者,如果您根本不使用 Spring Boot,那么可以用 XML 指定所有这些组件------过滤器链、 OpaqueTokenintrspector 和 OpaqueTokenAuthenticationConverter。

过滤器链是这样指定的:

Default Opaque Token Configuration

xml 复制代码
<http>
    <intercept-uri pattern="/**" access="authenticated"/>
    <oauth2-resource-server>
        <opaque-token introspector-ref="opaqueTokenIntrospector"
                authentication-converter-ref="opaqueTokenAuthenticationConverter"/>
    </oauth2-resource-server>
</http>

OpaqueToken 内省器是这样的:

Opaque Token Introspector

xml 复制代码
<bean id="opaqueTokenIntrospector"
        class="org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector">
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.introspection_uri}"/>
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.client_id}"/>
    <constructor-arg value="${spring.security.oauth2.resourceserver.opaquetoken.client_secret}"/>
</bean>

OpaqueTokenAuthenticationConverter 是这样的:

Opaque Token Authentication Converter

java 复制代码
<bean id="opaqueTokenAuthenticationConverter"
        class="com.example.CustomOpaqueTokenAuthenticationConverter"/>

Using introspectionUri()

授权服务器的内省 Uri 可以配置为配置属性,也可以在 DSL 中提供:

Introspection URI Configuration

java 复制代码
@Configuration
@EnableWebSecurity
public class DirectlyConfiguredIntrospectionUri {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspectionUri("https://idp.example.com/introspect")
                    .introspectionClientCredentials("client", "secret")
                )
            );
        return http.build();
    }
}

Using introspector()

比 intrspectionUri ()更强大的是 intrspector () ,它将完全取代 OpaqueTokenintrspector 的任何 Boot 自动配置:

Introspector Configuration

java 复制代码
@Configuration
@EnableWebSecurity
public class DirectlyConfiguredIntrospector {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaqueToken -> opaqueToken
                    .introspector(myCustomIntrospector())
                )
            );
        return http.build();
    }
}

当需要进行更深层的配置(如权限映射、 JWT 撤销或请求超时)时,这非常方便。

Exposing a OpaqueTokenIntrospector @Bean

或者,暴露一个 OpaqueTokenintrspector@Bean 的效果与 intrspector ()相同:

java 复制代码
@Bean
public OpaqueTokenIntrospector introspector() {
    return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
}

Configuring Authorization

OAuth 2.0内省端点通常会返回一个 scope 属性,指示它被授予的范围(或权限) ,例如:

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

在这种情况下,资源服务器将尝试将这些作用域强制转换为授权的权限列表,并在每个作用域前面加上字符串" SCOPE _"。

这意味着,为了保护源自不透明令牌的作用域的端点或方法,相应的表达式应包括以下前缀:

Authorization Opaque Token Configuration

java 复制代码
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration
@EnableWebSecurity
public class MappedAuthorities {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                .requestMatchers("/contacts/**").access(hasScope("contacts"))
                .requestMatchers("/messages/**").access(hasScope("messages"))
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
        return http.build();
    }
}

或者与方法安全类似:

java 复制代码
@PreAuthorize("hasAuthority('SCOPE_messages')")
public List<Message> getMessages(...) {}

Extracting Authorities Manually

默认情况下,Opaque Token 支持将从内省响应中提取范围声明,并将其解析为各个 GrantedAuthority 实例。

例如,如果内省反应是:

json 复制代码
{
    "active" : true,
    "scope" : "message:read message:write"
}

然后资源服务器将生成具有两个权限的身份验证,一个用于 message: read,另一个用于 message: write。

当然,这可以使用定制的 OpaqueTokenintrspector 进行定制,该定制查看属性集并以自己的方式进行转换:

java 复制代码
public class CustomAuthoritiesOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");

    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);
        return new DefaultOAuth2AuthenticatedPrincipal(
                principal.getName(), principal.getAttributes(), extractAuthorities(principal));
    }

    private Collection<GrantedAuthority> extractAuthorities(OAuth2AuthenticatedPrincipal principal) {
        List<String> scopes = principal.getAttribute(OAuth2IntrospectionClaimNames.SCOPE);
        return scopes.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
}

此后,可以通过将其公开为@Bean 来简单地配置这个自定义内省器:

java 复制代码
@Bean
public OpaqueTokenIntrospector introspector() {
    return new CustomAuthoritiesOpaqueTokenIntrospector();
}

Configuring Timeouts

默认情况下,资源服务器使用30秒的连接和套接字超时来协调授权服务器。

在某些情况下,这可能过于短暂。此外,它没有考虑更复杂的模式,如后退和发现。

为了调整 Resource Server 连接到授权服务器的方式,NimbusOpaqueTokenintrspector 接受 RestOperations 的一个实例:

java 复制代码
@Bean
public OpaqueTokenIntrospector introspector(RestTemplateBuilder builder, OAuth2ResourceServerProperties properties) {
    RestOperations rest = builder
            .basicAuthentication(properties.getOpaquetoken().getClientId(), properties.getOpaquetoken().getClientSecret())
            .setConnectTimeout(Duration.ofSeconds(60))
            .setReadTimeout(Duration.ofSeconds(60))
            .build();

    return new NimbusOpaqueTokenIntrospector(introspectionUri, rest);
}

Using Introspection with JWTs

一个常见的问题是自省是否与 JWT 兼容。Spring Security 的 Opaque Token 支持被设计为不关心令牌的格式ーー它会很高兴地将任何令牌传递给所提供的内省端点。

因此,假设您有一个需求,要求您在每个请求上与授权服务器进行检查,以防 JWT 被撤销。

即使您对令牌使用 JWT 格式,您的验证方法仍然是自省,这意味着您想要:

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: https://idp.example.org/introspection
          client-id: client
          client-secret: secret

在这种情况下,生成的身份验证将是 BearerTokenAuthentication。相应的 OAuth2AuthenticatedPrime 中的任何属性都将是内省端点返回的任何属性。

但是,让我们假设,非常奇怪的是,内省端点只返回无论令牌是否处于活动状态?

在这种情况下,您可以创建一个定制的 OpaqueTokenintrspector,它仍然命中端点,但是随后更新返回的主体,使 JWT 声明作为属性:

java 复制代码
public class JwtOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private JwtDecoder jwtDecoder = new NimbusJwtDecoder(new ParseOnlyJWTProcessor());

    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = this.delegate.introspect(token);
        try {
            Jwt jwt = this.jwtDecoder.decode(token);
            return new DefaultOAuth2AuthenticatedPrincipal(jwt.getClaims(), NO_AUTHORITIES);
        } catch (JwtException ex) {
            throw new OAuth2IntrospectionException(ex);
        }
    }

    private static class ParseOnlyJWTProcessor extends DefaultJWTProcessor<SecurityContext> {
    	JWTClaimsSet process(SignedJWT jwt, SecurityContext context)
                throws JOSEException {
            return jwt.getJWTClaimsSet();
        }
    }
}

此后,可以通过将其公开为@Bean 来简单地配置这个自定义内省器:

java 复制代码
@Bean
public OpaqueTokenIntrospector introspector() {
    return new JwtOpaqueTokenIntrospector();
}

Calling a /userinfo Endpoint

一般来说,资源服务器不关心底层用户,而是关心已授予的权限。

也就是说,有时将授权语句绑定到用户是有价值的。

如果应用程序也使用 spring-security-oauth2-client,并且已经设置了适当的 ClientRegistrationRepository,那么使用自定义 OpaqueTokenintrspector 就非常简单。下面的实现做了三件事:

  • 委托到内省端点,以确认令牌的有效性
  • 查找与/userinfo 端点关联的适当的客户端注册
  • 从/userinfo 端点调用并返回响应
java 复制代码
public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private final OAuth2UserService oauth2UserService = new DefaultOAuth2UserService();

    private final ClientRegistrationRepository repository;

    // ... constructor

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
        Instant issuedAt = authorized.getAttribute(ISSUED_AT);
        Instant expiresAt = authorized.getAttribute(EXPIRES_AT);
        ClientRegistration clientRegistration = this.repository.findByRegistrationId("registration-id");
        OAuth2AccessToken token = new OAuth2AccessToken(BEARER, token, issuedAt, expiresAt);
        OAuth2UserRequest oauth2UserRequest = new OAuth2UserRequest(clientRegistration, token);
        return this.oauth2UserService.loadUser(oauth2UserRequest);
    }
}

如果您没有使用 spring-security-oauth2-client,那么它仍然非常简单。您只需要使用您自己的 WebClient 实例调用/userinfo:

java 复制代码
public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
    private final OpaqueTokenIntrospector delegate =
            new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
    private final WebClient rest = WebClient.create();

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
        return makeUserInfoRequest(authorized);
    }
}

不管怎样,在创建了 OpaqueTokenintrspector 之后,您应该将其发布为@Bean 来覆盖默认值:

java 复制代码
@Bean
OpaqueTokenIntrospector introspector() {
    return new UserInfoOpaqueTokenIntrospector(...);
}

OAuth 2.0 Resource Server Multi-tenancy

Supporting both JWT and Opaque Token

在某些情况下,您可能需要同时访问这两种令牌。例如,您可能支持多个承租者,其中一个承租者发出 JWT,另一个发出不透明令牌。

如果这个决定必须在请求时做出,那么您可以使用 AuthenticationManagerResolver 来实现它,如下所示:

java 复制代码
@Bean
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver
        (JwtDecoder jwtDecoder, OpaqueTokenIntrospector opaqueTokenIntrospector) {
    AuthenticationManager jwt = new ProviderManager(new JwtAuthenticationProvider(jwtDecoder));
    AuthenticationManager opaqueToken = new ProviderManager(
            new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));
    return (request) -> useJwt(request) ? jwt : opaqueToken;
}

UseJwt (HttpServletRequest)的实现很可能取决于路径等自定义请求材料。

然后在 DSL 中指定 AuthenticationManagerResolver:

Authentication Manager Resolver

java 复制代码
http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
    );

Multi-tenancy

当有多种策略用于验证由某个租户标识符键控的持有者令牌时,资源服务器被认为是多租户的。

例如,资源服务器可能接受来自两个不同授权服务器的持有者令牌。或者,您的授权服务器可能代表多个发行者。

在每种情况下,都有两件事情需要去做,以及与你选择如何去做相关的权衡:

  • 解决租客的问题
  • 宣传租客

Resolving the Tenant By Claim

区分租户的一种方法是发行人索赔。由于发行者声明伴随签名的 JWT,所以可以使用 JwtIssuerAuthenticationManagerResolver 完成这项工作,如下所示:

Multi-tenancy Tenant by JWT Claim

java 复制代码
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
    .fromTrustedIssuers("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );

这很好,因为发行者端点是惰性加载的。事实上,相应的 JwtAuthenticationProvider 只有在发送具有相应发行者的第一个请求时才被实例化。这允许应用程序启动独立于启动和可用的授权服务器。

Dynamic Tenants

当然,您可能不希望每次添加新租户时都重新启动应用程序。在这种情况下,可以使用 AuthenticationManager 实例存储库配置 JwtIssuerAuthenticationManagerResolver,您可以在运行时编辑这些实例,如下所示:

java 复制代码
private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
	JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
	        (JwtDecoders.fromIssuerLocation(issuer));
	authenticationManagers.put(issuer, authenticationProvider::authenticate);
}

// ...

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
        new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );

在这种情况下,使用一种策略构造 JwtIssuerAuthenticationManagerResolver,以获取给定发行者的 AuthenticationManager。这种方法允许我们在运行时从存储库中添加和删除元素(在代码片段中显示为 Map)。

简单地使用任何发行者并从中构造 AuthenticationManager 都是不安全的。发行者应该是代码可以从可信源(如允许的发行者列表)验证的发行者。

Parsing the Claim Only Once

您可能已经注意到,这种策略虽然简单,但是在 JWT 被 AuthenticationManagerResolver 解析一次,然后在随后的请求中再由 JwtDecder 解析一次的情况下,会产生折衷。

这种额外的解析可以通过使用来自 Nimbus 的 JWTClamsSetAwareJWSKeySelector 直接配置 JwtDecder 来减轻:

java 复制代码
@Component
public class TenantJWSKeySelector
    implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {

	private final TenantRepository tenants; // 1
	private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); // 2

	public TenantJWSKeySelector(TenantRepository tenants) {
		this.tenants = tenants;
	}

	@Override
	public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext)
			throws KeySourceException {
		return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
				.selectJWSKeys(jwsHeader, securityContext);
	}

	private String toTenant(JWTClaimsSet claimSet) {
		return (String) claimSet.getClaim("iss");
	}

	private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
		return Optional.ofNullable(this.tenants.findById(tenant)) // 3
		        .map(t -> t.getAttrbute("jwks_uri"))
				.map(this::fromUri)
				.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
	}

	private JWSKeySelector<SecurityContext> fromUri(String uri) {
		try {
			return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); // 4
		} catch (Exception ex) {
			throw new IllegalArgumentException(ex);
		}
	}
}
  1. 租户信息的假设来源
  2. " JWKKeySelector"的缓存,由租户标识符键控
  3. 查找租户比简单地动态计算 JWK Set 端点更安全------查找作为允许租户的列表
  4. 通过从 JWK Set 端点返回的键类型创建一个 JWSKeySelector ------这里的延迟查找意味着您不需要在启动时配置所有租户

上面的键选择器是由许多键选择器组成的。它根据 JWT 中的 iss 声明选择要使用的密钥选择器。

要使用这种方法,请确保将授权服务器配置为包含声明集作为令牌签名的一部分。如果没有这个,你就不能保证发行人没有被一个不好的行为者改变。

接下来,我们可以构造一个 JWTProcessor:

java 复制代码
@Bean
JWTProcessor jwtProcessor(JWTClaimsSetAwareJWSKeySelector keySelector) {
	ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor();
	jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
	return jwtProcessor;
}

正如您已经看到的,将租户意识下移到这个级别的代价是更多的配置。我们还有一点。

接下来,我们仍然希望确保您正在验证发行者。但是,由于每个 JWT 的发行者可能不同,因此您还需要一个支持租户的验证器:

java 复制代码
@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
    private final TenantRepository tenants;

    private final OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid",
            "https://tools.ietf.org/html/rfc6750#section-3.1");

    public TenantJwtIssuerValidator(TenantRepository tenants) {
        this.tenants = tenants;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt token) {
        if(this.tenants.findById(token.getIssuer()) != null) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(this.error);
    }
}

现在我们已经有了一个能够识别租户的处理器和一个能够识别租户的验证器,我们可以继续创建我们的 JwtDecder:

java 复制代码
@Bean
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
	NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
	OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
			(JwtValidators.createDefault(), jwtValidator);
	decoder.setJwtValidator(validator);
	return decoder;
}

我们已经讨论过解决租客的问题了。

如果您选择通过 JWT 声明以外的方式解决租户问题,那么您需要确保以相同的方式处理下游资源服务器。例如,如果通过子域解析它,则可能需要使用相同的子域寻址下游资源服务器。

但是,如果您通过持有者令牌中的声明来解析它,请继续阅读以了解 Spring Security 对持有者令牌传播的支持。

OAuth 2.0 Bearer Tokens

Bearer Token Resolution

默认情况下,资源服务器在授权标头中查找持有者令牌。然而,这可以通过几种方式进行定制。

Reading the Bearer Token from a Custom Header

例如,您可能需要从自定义标头读取持有者令牌。为此,可以将 DefaultBearerTokenResolver 作为 bean 公开,或者将一个实例连接到 DSL,如下面的示例所示:

Custom Bearer Token Header

java 复制代码
@Bean
BearerTokenResolver bearerTokenResolver() {
    DefaultBearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
    bearerTokenResolver.setBearerTokenHeaderName(HttpHeaders.PROXY_AUTHORIZATION);
    return bearerTokenResolver;
}

或者,在提供程序同时使用自定义标头和值的情况下,可以改为使用 HeaderBearerTokenResolver。

Reading the Bearer Token from a Form Parameter

或者,您可能希望从表单参数中读取令牌,这可以通过配置 DefaultBearerTokenResolver 来完成,如下所示:

Form Parameter Bearer Token

java 复制代码
DefaultBearerTokenResolver resolver = new DefaultBearerTokenResolver();
resolver.setAllowFormEncodedBodyParameter(true);
http
    .oauth2ResourceServer(oauth2 -> oauth2
        .bearerTokenResolver(resolver)
    );

Bearer Token Propagation

既然资源服务器已经验证了令牌,将其传递给下游服务可能会很方便。对于 ServletBearerExchangeFilterfunction,这非常简单,您可以在下面的示例中看到:

java 复制代码
@Bean
public WebClient rest() {
    return WebClient.builder()
            .filter(new ServletBearerExchangeFilterFunction())
            .build();
}

当上面的 WebClient 用于执行请求时,Spring Security 将查找当前的 Authentication 并提取任何 AbstractOAuth2Token 凭证。然后,它将在 Authorization 头中传播该令牌。

For example:

java 复制代码
this.rest.get()
        .uri("https://other-service.example.com/endpoint")
        .retrieve()
        .bodyToMono(String.class)
        .block()

将调用 other-service.example.com/endpoint ,为您添加持有者令牌 Authorization 头。

在需要覆盖此行为的地方,只需要自己提供头文件,如下所示:

java 复制代码
this.rest.get()
        .uri("https://other-service.example.com/endpoint")
        .headers(headers -> headers.setBearerAuth(overridingToken))
        .retrieve()
        .bodyToMono(String.class)
        .block()

在这种情况下,过滤器将后退并简单地将请求转发到 Web 过滤器链的其余部分。

与 OAuth 2.0 Client filter 函数不同,如果令牌过期,这个 filter 函数不会尝试更新令牌。要获得此级别的支持,请使用 OAuth 2.0客户端过滤器。

RestTemplate support

目前没有相当于 ServletBearerExchangeFilterfunction 的 RestTemplate,但是您可以使用自己的拦截器简单地传播请求的持有者令牌:

java 复制代码
@Bean
RestTemplate rest() {
	RestTemplate rest = new RestTemplate();
	rest.getInterceptors().add((request, body, execution) -> {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (authentication == null) {
			return execution.execute(request, body);
		}

		if (!(authentication.getCredentials() instanceof AbstractOAuth2Token)) {
			return execution.execute(request, body);
		}

		AbstractOAuth2Token token = (AbstractOAuth2Token) authentication.getCredentials();
	    request.getHeaders().setBearerAuth(token.getTokenValue());
	    return execution.execute(request, body);
	});
	return rest;
}

与 OAuth 2.0 Authorized Client Manager 不同,如果令牌过期,这个过滤器拦截器不会尝试更新令牌。要获得这种级别的支持,请使用 OAuth 2.0 Authorized Client Manager 创建一个拦截器。

Bearer Token Failure

持有者令牌可能由于多种原因而无效。例如,令牌可能不再处于活动状态。

在这些情况下,资源服务器引发 InvalidBearerTokenException。与其他异常一样,这会导致 OAuth 2.0 Bearer Token 错误响应:

java 复制代码
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error_code="invalid_token", error_description="Unsupported algorithm of none", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

此外,它还作为 AuthenticationfalureBadCreentialsEvent 发布,您可以在应用程序中侦听这个事件,如下所示:

java 复制代码
@Component
public class FailureEvents {
	@EventListener
    public void onFailure(AuthenticationFailureBadCredentialsEvent badCredentials) {
		if (badCredentials.getAuthentication() instanceof BearerTokenAuthenticationToken) {
		    // ... handle
        }
    }
}
相关推荐
LiberInfo3 分钟前
Docker + Nacos + Spring Cloud Gateway 实现简单的动态路由配置修改和动态路由发现
java·spring boot·spring cloud·docker·nacos·gateway·动态路由
罗曼蒂克在消亡1 小时前
拦截器和过滤器
java·servlet
茉么乔2 小时前
java excel poi导出 支持多表头的公共导出方法
java·excel·poi
AskHarries3 小时前
Spring Boot集成protobuf快速入门Demo
java·spring boot·后端·protobuf
励志秃头码代码5 小时前
面试笔记 8.5
java·面试·职场和发展
阿诚学java5 小时前
数据结构与算法-15高级数据结构_树论(堆树)
java·数据结构
极客先躯5 小时前
一个是生产打包的时候, 一个是本地测试启动的时候,maven如何配置?
java·maven·动态配置
一只懒鱼a5 小时前
SpringBoot 集成原生 Servlet、Filter、Listener
java·spring boot·后端·spring
未来的JAVA高级开发工程师5 小时前
使用SpringAOP实现公共字段填充
java·开发语言·后端·spring·mybatis
mischen5206 小时前
Spring JDBC提供了几种类型来简化数据库开发?
数据库·spring·数据库开发