Spring Security OAuth2客户端刷新Token失效的正确实现

Spring Security OAuth2 客户端刷新 Token 失效的正确实现

大家好,今天我们来深入探讨 Spring Security OAuth2 客户端刷新 Token 失效的正确实现。这是一个在实际应用中经常遇到的问题,如果处理不当,会导致用户体验下降,甚至引发安全问题。

1. 刷新 Token 的基本概念

在 OAuth 2.0 协议中,刷新 Token (Refresh Token) 用于在 Access Token 过期后,无需用户再次授权,即可获取新的 Access Token。 这种机制避免了频繁的用户交互,提升了用户体验。

  • Access Token: 用于访问受保护资源的令牌,具有有效期。
  • Refresh Token: 用于获取新的 Access Token 的令牌,通常具有比 Access Token 更长的有效期,甚至可以无限期有效(直到被显式撤销)。

2. 刷新 Token 失效的常见原因

刷新 Token 的失效可能由多种原因引起:

  • 过期: 刷新 Token 自身也可能具有有效期,过期后无法使用。
  • 撤销: 授权服务器 (Authorization Server) 可能会撤销刷新 Token,例如用户主动撤销授权,或者服务器检测到异常行为。
  • 重用: 有些授权服务器实现为了安全考虑,限制刷新 Token 的单次使用。 每次使用后,旧的刷新 Token 会被作废,并返回一个新的刷新 Token。
  • 客户端信息变更: 客户端的配置信息发生变更,例如 Client ID 或 Client Secret 被修改,会导致之前颁发的刷新 Token 失效。
  • 服务端配置: 授权服务器的配置变更,比如签名密钥的更换,也会影响刷新 Token 的有效性。

3. Spring Security OAuth2 客户端的刷新 Token 机制

Spring Security OAuth2 客户端提供了对刷新 Token 的支持,但我们需要正确配置和处理刷新 Token 失效的情况。

3.1 依赖配置

确保项目中包含了 Spring Security OAuth2 客户端的依赖。如果是 Maven 项目,可以在 pom.xml 文件中添加以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

3.2 客户端配置

application.ymlapplication.properties 文件中配置 OAuth2 客户端信息:

spring:
  security:
    oauth2:
      client:
        registration:
          my-client:  # 客户端ID,自定义名称
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code  # 授权码模式
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # 回调地址
            scope:
              - read
              - write
        provider:
          my-client: # 客户端ID,与registration保持一致
            authorization-uri: your-authorization-uri  # 授权服务器的授权端点
            token-uri: your-token-uri # 授权服务器的令牌端点
            user-info-uri: your-user-info-uri # (可选) 用户信息端点
            jwk-set-uri: your-jwk-set-uri # (可选) JWK Set URI

3.3 刷新 Token 的存储

Spring Security OAuth2 客户端默认将 Access Token 和 Refresh Token 存储在 OAuth2AuthorizedClient 对象中。 可以通过 OAuth2AuthorizedClientService 接口来管理和获取这些信息。

  • OAuth2AuthorizedClientService: 接口定义了保存、查找和删除 OAuth2AuthorizedClient 的方法。
  • InMemoryOAuth2AuthorizedClientService: 默认实现,将 OAuth2AuthorizedClient 存储在内存中。 不推荐在生产环境中使用,因为重启后数据会丢失。
  • 自定义实现: 推荐自定义 OAuth2AuthorizedClientService 实现,将 OAuth2AuthorizedClient 存储在数据库、Redis 或其他持久化存储中。

3.4 自定义 OAuth2AuthorizedClientService

以下是一个使用 JDBC 将 OAuth2AuthorizedClient 存储在数据库中的示例:

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.stereotype.Service;

import java.time.ZoneId;
import java.util.List;
import java.util.Map;

@Service
public class JdbcOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {

    private final JdbcTemplate jdbcTemplate;

    public JdbcOAuth2AuthorizedClientService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
        String sql = "SELECT * FROM oauth2_authorized_client WHERE client_registration_id = ? AND principal_name = ?";
        List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, clientRegistrationId, principalName);

        if (results.isEmpty()) {
            return null;
        }

        Map<String, Object> result = results.get(0);

        OAuth2AccessToken accessToken = new OAuth2AccessToken(
                OAuth2AccessToken.TokenType.BEARER,
                (String) result.get("access_token_value"),
                ((java.sql.Timestamp) result.get("access_token_issued_at")).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(),
                ((java.sql.Timestamp) result.get("access_token_expires_at")).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()
        );

        OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(
                (String) result.get("refresh_token_value"),
                ((java.sql.Timestamp) result.get("refresh_token_issued_at")).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()
        );

        return (T) new OAuth2AuthorizedClient(
                (String) result.get("client_registration_id"),
                (String) result.get("principal_name"),
                accessToken,
                refreshToken
        );
    }

    @Override
    public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
        String sql = "INSERT INTO oauth2_authorized_client (client_registration_id, principal_name, access_token_type, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE access_token_type = ?, access_token_value = ?, access_token_issued_at = ?, access_token_expires_at = ?, access_token_scopes = ?, refresh_token_value = ?, refresh_token_issued_at = ?";

        jdbcTemplate.update(sql,
                authorizedClient.getClientRegistration().getRegistrationId(),
                principal.getName(),
                authorizedClient.getAccessToken().getTokenType().getValue(),
                authorizedClient.getAccessToken().getTokenValue(),
                java.sql.Timestamp.from(authorizedClient.getAccessToken().getIssuedAt().atZone(ZoneId.systemDefault()).toInstant()),
                java.sql.Timestamp.from(authorizedClient.getAccessToken().getExpiresAt().atZone(ZoneId.systemDefault()).toInstant()),
                String.join(",", authorizedClient.getAccessToken().getScopes()),
                authorizedClient.getRefreshToken().getTokenValue(),
                java.sql.Timestamp.from(authorizedClient.getRefreshToken().getIssuedAt().atZone(ZoneId.systemDefault()).toInstant()),

                authorizedClient.getAccessToken().getTokenType().getValue(),
                authorizedClient.getAccessToken().getTokenValue(),
                java.sql.Timestamp.from(authorizedClient.getAccessToken().getIssuedAt().atZone(ZoneId.systemDefault()).toInstant()),
                java.sql.Timestamp.from(authorizedClient.getAccessToken().getExpiresAt().atZone(ZoneId.systemDefault()).toInstant()),
                String.join(",", authorizedClient.getAccessToken().getScopes()),
                authorizedClient.getRefreshToken().getTokenValue(),
                java.sql.Timestamp.from(authorizedClient.getRefreshToken().getIssuedAt().atZone(ZoneId.systemDefault()).toInstant())
        );
    }

    @Override
    public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
        String sql = "DELETE FROM oauth2_authorized_client WHERE client_registration_id = ? AND principal_name = ?";
        jdbcTemplate.update(sql, clientRegistrationId, principalName);
    }
}

注意:

  • 你需要创建相应的数据库表 oauth2_authorized_client
  • SQL 语句中的字段名需要与数据库表中的字段名保持一致。
  • 需要注入 JdbcTemplate

数据库表结构示例:

CREATE TABLE `oauth2_authorized_client` (
  `client_registration_id` varchar(100) NOT NULL,
  `principal_name` varchar(200) NOT NULL,
  `access_token_type` varchar(100) NOT NULL,
  `access_token_value` blob NOT NULL,
  `access_token_issued_at` timestamp NOT NULL,
  `access_token_expires_at` timestamp NOT NULL,
  `access_token_scopes` varchar(1000) DEFAULT NULL,
  `refresh_token_value` blob DEFAULT NULL,
  `refresh_token_issued_at` timestamp DEFAULT NULL,
  PRIMARY KEY (`client_registration_id`,`principal_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

配置自定义 OAuth2AuthorizedClientService:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;

@Configuration
public class OAuth2Config {

    @Bean
    public OAuth2AuthorizedClientService authorizedClientService(JdbcTemplate jdbcTemplate) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate);
    }
}

4. 处理刷新 Token 失效

当刷新 Token 失效时,我们需要捕获相应的异常,并采取相应的措施,例如:

  • OAuth2AuthorizationException: 当 OAuth2 授权过程中发生错误时抛出,包括刷新 Token 失效。

4.1 使用 OAuth2AuthorizedClientManager 刷新 Token

OAuth2AuthorizedClientManager 用于管理 OAuth2AuthorizedClient 并处理 Access Token 的刷新。

import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.stereotype.Component;

@Component
public class MyOAuth2Client {

    private final DefaultOAuth2AuthorizedClientManager authorizedClientManager;
    private final ClientRegistrationRepository clientRegistrationRepository;
    private final OAuth2AuthorizedClientService authorizedClientService;

    public MyOAuth2Client(ClientRegistrationRepository clientRegistrationRepository,
                          OAuth2AuthorizedClientService authorizedClientService,
                          OAuth2AuthorizedClientRepository authorizedClientRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
        this.authorizedClientService = authorizedClientService;
        this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
    }

    public OAuth2AuthorizedClient getAuthorizedClient(String clientRegistrationId, String principalName) {
        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                .principal(principalName)
                .build();

        return authorizedClientManager.authorize(authorizeRequest);
    }

    public String getAccessToken(String clientRegistrationId, String principalName) {
        OAuth2AuthorizedClient authorizedClient = getAuthorizedClient(clientRegistrationId, principalName);
        if (authorizedClient != null) {
            return authorizedClient.getAccessToken().getTokenValue();
        }
        return null;
    }
}

4.2 全局异常处理

可以使用 @ControllerAdvice 注解创建一个全局异常处理器,捕获 OAuth2AuthorizationException 异常,并根据具体情况进行处理。

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OAuth2AuthenticationException.class)
    public ResponseEntity<String> handleOAuth2AuthenticationException(OAuth2AuthenticationException ex) {
        // 检查异常是否是由于刷新 Token 失效引起的
        if (ex.getMessage().contains("invalid_grant") || ex.getMessage().contains("Token has been revoked")) {
            // 刷新 Token 失效,需要重新授权
            // 可以清除本地存储的 OAuth2AuthorizedClient,然后重定向到授权页面
            // 或者返回一个错误提示给前端,提示用户重新登录
            return new ResponseEntity<>("Refresh token is invalid. Please re-authenticate.", HttpStatus.UNAUTHORIZED);
        } else {
            // 其他 OAuth2 授权异常
            return new ResponseEntity<>("OAuth2 authentication failed: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleException(Exception ex) {
        // 处理其他异常
        return new ResponseEntity<>("An unexpected error occurred: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

4.3 前端处理

前端需要根据后端返回的错误信息,判断是否需要重新授权。 如果刷新 Token 失效,可以清除本地存储的 Access Token 和 Refresh Token,然后重定向到授权页面。

5. 最佳实践

  • 持久化存储: 使用持久化存储(如数据库、Redis)存储 OAuth2AuthorizedClient,避免重启后数据丢失。
  • 监控和告警: 监控刷新 Token 的使用情况,及时发现异常情况。
  • 安全加固: 对刷新 Token 进行加密存储,防止泄露。
  • 定期轮换: 定期轮换刷新 Token,提高安全性。
  • 考虑 PKCE: 在授权码模式中使用 PKCE (Proof Key for Code Exchange) 来防止授权码被拦截。
  • 错误重试机制: 在获取 Access Token 失败时,实现适当的重试机制,但需要注意避免无限循环。

6. 总结:妥善管理刷新令牌,确保安全流畅的访问

正确实现 Spring Security OAuth2 客户端刷新 Token 失效的处理机制,是保证应用程序安全性和用户体验的关键。 包括选择合适的存储方案,处理刷新令牌失效的异常情况,并采取最佳实践来加固安全性。 这些步骤能够确保用户可以安全流畅地访问受保护的资源。

发表回复

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