Spring Security的Opaque Token内省机制:OAuth2服务间的信任传递

Spring Security的Opaque Token内省机制:OAuth2服务间的信任传递

大家好,今天我们要深入探讨Spring Security中Opaque Token内省机制,以及它在OAuth2服务间信任传递中扮演的关键角色。

OAuth2授权框架允许第三方应用(客户端)访问用户的受保护资源,而无需暴露用户的凭据(例如用户名和密码)。这种访问通常通过访问令牌(Access Token)来完成。然而,在微服务架构中,资源服务器需要验证客户端提供的访问令牌是否有效,以及该令牌是否具有访问受保护资源的权限。这就是Opaque Token内省机制发挥作用的地方。

OAuth2 授权框架简介

在深入Opaque Token内省之前,让我们快速回顾一下OAuth2授权框架中的关键角色和流程:

  • 资源所有者(Resource Owner): 拥有受保护资源的用户。
  • 客户端(Client): 想要访问资源所有者受保护资源的应用。
  • 授权服务器(Authorization Server): 负责认证资源所有者身份,并颁发访问令牌。
  • 资源服务器(Resource Server): 托管受保护资源,并验证访问令牌以授权客户端访问。

典型的OAuth2流程如下:

  1. 客户端向授权服务器请求授权。
  2. 授权服务器认证资源所有者,并获得授权。
  3. 授权服务器向客户端颁发访问令牌(Access Token)。
  4. 客户端使用访问令牌向资源服务器请求受保护资源。
  5. 资源服务器验证访问令牌,并根据令牌中的权限授予客户端访问权限。

Opaque Token 与 JWT

访问令牌可以采用不同的格式,其中最常见的两种是:

  • JSON Web Token (JWT): JWT是一种自包含的令牌,包含了关于令牌本身的信息,例如颁发者、过期时间、权限等。资源服务器可以通过验证JWT的签名来验证令牌的有效性,而无需与授权服务器通信。
  • Opaque Token: Opaque Token是一种不透明的令牌,资源服务器无法直接从令牌本身获取任何信息。资源服务器需要与授权服务器通信,通过内省端点(Introspection Endpoint)来验证令牌的有效性并获取令牌相关的信息。
特性 JWT Opaque Token
自包含性 是,包含所有必要信息 否,仅包含一个引用
验证方式 验证签名 调用授权服务器的内省端点
安全性 取决于签名算法和密钥管理 更安全,因为令牌信息不直接暴露
性能 验证快速,无需网络请求 验证需要网络请求,性能相对较低
适用场景 资源服务器可以离线验证令牌的场景 需要更高级别的安全性和控制的场景
令牌大小 相对较大 较小

选择JWT还是Opaque Token取决于具体的应用场景和安全需求。如果资源服务器需要快速验证令牌,并且可以离线验证,那么JWT是一个不错的选择。如果需要更高级别的安全性和控制,并且资源服务器可以容忍额外的网络请求,那么Opaque Token更合适。

Opaque Token 内省机制

Opaque Token 内省机制允许资源服务器通过调用授权服务器的内省端点来验证Opaque Token的有效性,并获取令牌相关的信息。内省端点通常需要客户端认证,以防止未经授权的访问。

内省流程:

  1. 客户端(代表资源服务器)向授权服务器的内省端点发送请求,请求中包含要验证的Opaque Token。
  2. 授权服务器验证客户端的身份,并验证Opaque Token的有效性。
  3. 如果令牌有效,授权服务器返回一个JSON响应,包含令牌相关的信息,例如:
    • active: 指示令牌是否有效(true/false)。
    • scope: 令牌的权限范围。
    • client_id: 令牌所属的客户端ID。
    • username: 令牌关联的用户名。
    • expires_in: 令牌的剩余有效时间(秒)。
  4. 资源服务器根据内省响应中的信息来授权客户端访问受保护资源。

内省请求示例:

POST /oauth/introspect HTTP/1.1
Host: authorization-server.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <Base64 encoded client_id:client_secret>

token=opaque_token_value

内省响应示例(令牌有效):

{
  "active": true,
  "scope": "read write",
  "client_id": "client_id",
  "username": "user123",
  "exp": 1678886400
}

内省响应示例(令牌无效):

{
  "active": false
}

Spring Security 中的 Opaque Token 内省

Spring Security提供了对Opaque Token内省的内置支持,可以简化资源服务器的开发。

添加依赖:

首先,需要在pom.xml文件中添加spring-boot-starter-oauth2-resource-server依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

配置资源服务器:

接下来,需要在application.ymlapplication.properties文件中配置资源服务器,指定内省端点的URL和客户端认证信息。

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: http://authorization-server.example.com/oauth/introspect
          client-id: resource-server-client
          client-secret: secret
  • introspection-uri: 授权服务器的内省端点URL。
  • client-id: 资源服务器作为客户端在授权服务器中注册的客户端ID。
  • client-secret: 资源服务器作为客户端在授权服务器中注册的客户端密钥。

配置 Spring Security:

最后,需要配置Spring Security,启用Opaque Token验证。

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/public").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(Customizer.withDefaults())
            );
        return http.build();
    }
}

这段代码配置了Spring Security,使得所有请求都需要认证,但/public路径下的请求除外。oauth2ResourceServer配置启用了OAuth2资源服务器,并使用opaqueToken配置来指定使用Opaque Token验证。Customizer.withDefaults() 使用默认配置,也可以自定义 OpaqueTokenIntrospector

自定义 OpaqueTokenIntrospector:

如果需要自定义Opaque Token内省的逻辑,可以实现OpaqueTokenIntrospector接口。

@Component
public class CustomOpaqueTokenIntrospector implements OpaqueTokenIntrospector {

    private final RestOperations restOperations;
    private final String introspectionUri;
    private final String clientId;
    private final String clientSecret;

    public CustomOpaqueTokenIntrospector(
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") String introspectionUri,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") String clientId,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") String clientSecret) {
        this.introspectionUri = introspectionUri;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.restOperations = new RestTemplate();
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBasicAuth(this.clientId, this.clientSecret);
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("token", token);

        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);

        try {
            ResponseEntity<Map> response = this.restOperations
                    .exchange(this.introspectionUri, HttpMethod.POST, entity, Map.class);

            Map<String, Object> claims = response.getBody();

            if (response.getStatusCode() == HttpStatus.OK && (Boolean) claims.get("active")) {
                return new DefaultOAuth2AuthenticatedPrincipal(claims, Collections.emptyList(), "sub"); // "sub" is a common claim for the user's unique identifier
            }
        } catch (Exception ex) {
            // Handle introspection error (e.g., network issue, invalid response)
            throw new BadCredentialsException("Could not introspect token", ex);
        }

        throw new BadCredentialsException("Invalid token");
    }
}

然后,需要在Spring Security配置中指定使用自定义的OpaqueTokenIntrospector

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Autowired
    private CustomOpaqueTokenIntrospector customOpaqueTokenIntrospector;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/public").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaque -> opaque.introspector(customOpaqueTokenIntrospector))
            );
        return http.build();
    }
}

代码解释:

  • CustomOpaqueTokenIntrospector 类实现了 OpaqueTokenIntrospector 接口,负责与授权服务器的内省端点进行交互。
  • 构造函数接收内省端点 URI、客户端 ID 和客户端密钥,这些信息用于构建内省请求。
  • introspect(String token) 方法是核心方法,它接收访问令牌作为输入,并返回一个 OAuth2AuthenticatedPrincipal 对象,该对象包含有关令牌的信息。
  • 方法内部使用 RestTemplate 发送 POST 请求到内省端点,请求头包含 Basic 认证信息,请求体包含访问令牌。
  • 如果内省请求成功并且令牌处于活动状态,则从响应中提取声明(claims)并创建一个 DefaultOAuth2AuthenticatedPrincipal 对象。
  • 如果内省请求失败或令牌无效,则抛出 BadCredentialsException 异常。

错误处理

在实现 Opaque Token 内省时,需要考虑各种错误情况,例如:

  • 网络错误: 资源服务器无法连接到授权服务器。
  • 授权服务器错误: 授权服务器返回错误响应。
  • 无效的令牌: 令牌已过期或被撤销。
  • 无效的客户端认证: 资源服务器提供的客户端ID或密钥不正确。

CustomOpaqueTokenIntrospector示例中,我们使用try-catch块来处理网络错误和其他异常,并抛出BadCredentialsException异常,以便Spring Security可以正确处理认证失败。

缓存

为了提高性能,可以缓存Opaque Token内省的结果。Spring Security 提供了默认的缓存机制,也可以自定义缓存实现。

启用默认缓存:

Spring Security 的默认缓存是基于 ConcurrentHashMap 的,可以通过配置来调整缓存的大小和过期时间。 但是默认情况下没有开启,需要手动配置。

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Autowired
    private CustomOpaqueTokenIntrospector customOpaqueTokenIntrospector;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/public").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaque -> opaque
                        .introspector(customOpaqueTokenIntrospector)
                        .introspectionClientCredentials("resource-server-client", "secret")
                        .introspectionUri("http://authorization-server.example.com/oauth/introspect")
                )
            );
        return http.build();
    }

    @Bean
    public OpaqueTokenIntrospector opaqueTokenIntrospector() {
        return new NimbusOpaqueTokenIntrospector(
                "http://authorization-server.example.com/oauth/introspect",
                "resource-server-client",
                "secret");
    }
}

或者在application.yml中配置

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: http://authorization-server.example.com/oauth/introspect
          client-id: resource-server-client
          client-secret: secret
          cache:
            enabled: true  # Enable caching
            max-size: 1000  # Maximum number of entries in the cache
            ttl: 3600       # Time to live for each entry in seconds (1 hour)

注意,如果启用了缓存,需要确保缓存的过期时间与访问令牌的过期时间一致,以避免安全问题。

自定义缓存实现:

可以使用Spring Cache抽象来集成不同的缓存提供程序,例如Redis或Memcached。

首先,需要添加相应的缓存提供程序的依赖。例如,如果使用Redis,需要添加spring-boot-starter-data-redis依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后,需要配置缓存管理器。

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(3600)) // 设置缓存过期时间
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(cacheConfiguration)
                .build();
    }
}

最后,需要在CustomOpaqueTokenIntrospector中使用缓存管理器来缓存内省结果。

@Component
public class CustomOpaqueTokenIntrospector implements OpaqueTokenIntrospector {

    private final RestOperations restOperations;
    private final String introspectionUri;
    private final String clientId;
    private final String clientSecret;

    @Autowired
    private CacheManager cacheManager;

    public CustomOpaqueTokenIntrospector(
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") String introspectionUri,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") String clientId,
            @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") String clientSecret) {
        this.introspectionUri = introspectionUri;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.restOperations = new RestTemplate();
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        Cache cache = cacheManager.getCache("opaqueToken");
        OAuth2AuthenticatedPrincipal principal = cache.get(token, () -> {
            HttpHeaders headers = new HttpHeaders();
            headers.setBasicAuth(this.clientId, this.clientSecret);
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

            MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
            body.add("token", token);

            HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);

            try {
                ResponseEntity<Map> response = this.restOperations
                        .exchange(this.introspectionUri, HttpMethod.POST, entity, Map.class);

                Map<String, Object> claims = response.getBody();

                if (response.getStatusCode() == HttpStatus.OK && (Boolean) claims.get("active")) {
                    return new DefaultOAuth2AuthenticatedPrincipal(claims, Collections.emptyList(), "sub"); // "sub" is a common claim for the user's unique identifier
                }
            } catch (Exception ex) {
                // Handle introspection error (e.g., network issue, invalid response)
                throw new BadCredentialsException("Could not introspect token", ex);
            }

            throw new BadCredentialsException("Invalid token");
        });
        return principal;
    }
}

总结一下关键点

Opaque Token 内省机制是一种安全可靠的OAuth2令牌验证方式,尤其适用于微服务架构。Spring Security提供了对Opaque Token内省的内置支持,可以简化资源服务器的开发。为了提高性能,可以缓存Opaque Token内省的结果。通过合理配置和自定义,可以构建安全高效的OAuth2资源服务器。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注