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.yml 或 application.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 失效的处理机制,是保证应用程序安全性和用户体验的关键。 包括选择合适的存储方案,处理刷新令牌失效的异常情况,并采取最佳实践来加固安全性。 这些步骤能够确保用户可以安全流畅地访问受保护的资源。