Spring Cloud Gateway + OAuth2.1 + PKCE:安全对接移动端 App,防止 Token 泄露
今天咱们聊聊一个在移动端开发中非常关键的安全问题:OAuth2.1 + PKCE 认证。
移动端认证的痛点
在我们的日常开发工作中,经常会遇到这样的场景:
- 移动端App需要安全地获取访问令牌
- 传统的客户端密钥方式在移动端不安全
- Token容易被窃取或泄露
- 需要防范各种攻击手段
传统的OAuth2.0在公共客户端(如移动App)上存在安全隐患,因为客户端密钥无法安全存储。今天我们就来聊聊如何用OAuth2.1 + PKCE解决这些问题。
解决方案思路
今天我们要解决的,就是如何用Spring Cloud Gateway + OAuth2.1 + PKCE构建一个安全的移动端认证方案。
核心思路是:
- PKCE机制:防止授权码被劫持
- 网关统一认证:在网关层处理认证逻辑
- Token安全传输:确保令牌安全分发
- 动态密钥管理:避免静态密钥风险
技术选型
- Spring Cloud Gateway:API网关
- Spring Security OAuth2.1:认证授权框架
- PKCE(Proof Key for Code Exchange):防止授权码劫持
- Redis:Token存储和管理
- JWT:令牌格式
核心实现思路
1. PKCE机制原理
PKCE(Proof Key for Code Exchange)是OAuth2.1中为公共客户端设计的安全增强机制:
/**
* PKCE工具类
*/
@Component
public class PkceUtils {
/**
* 生成Code Verifier
*/
public static String generateCodeVerifier() {
SecureRandom secureRandom = new SecureRandom();
byte[] codeVerifier = new byte[32];
secureRandom.nextBytes(codeVerifier);
return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
}
/**
* 生成Code Challenge
*/
public static String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
2. 网关配置
配置Spring Cloud Gateway的OAuth2.1认证:
# application.yml
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/user/**
filters:
- name: AuthenticationFilter
args:
require-auth: true
- id: public-api
uri: lb://public-service
predicates:
- Path=/api/public/**
default-filters:
- AuthenticationGlobalFilter
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://auth-server/oauth2/token
jwk-set-uri: http://auth-server/oauth2/jwks
3. 认证过滤器
实现网关层的认证过滤器:
@Component
@Slf4j
public class AuthenticationFilter implements GlobalFilter, Ordered {
@Autowired
private ReactiveAuthenticationManager authenticationManager;
@Autowired
private ServerResponseConverter responseConverter;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
// 检查是否需要认证
if (requiresAuthentication(path)) {
return authenticateAndContinue(exchange, chain);
}
return chain.filter(exchange);
}
private Mono<Void> authenticateAndContinue(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 从请求头中获取Token
String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return unauthorizedResponse(exchange, "Missing or invalid Authorization header");
}
String token = authHeader.substring(7);
// 验证Token
return authenticationManager.authenticate(
new JwtAuthenticationToken(token)
).flatMap(authentication -> {
// 设置认证信息到exchange
exchange.getAttributes().put("authentication", authentication);
return chain.filter(exchange);
}).onErrorResume(throwable -> {
log.error("Authentication failed", throwable);
return unauthorizedResponse(exchange, "Invalid token");
});
}
private boolean requiresAuthentication(String path) {
// 定义需要认证的路径
return path.startsWith("/api/user/") ||
path.startsWith("/api/admin/") ||
path.startsWith("/api/private/");
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body = "{\"error\":\"unauthorized\",\"message\":\"" + message + "\"}";
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -1; // 在其他过滤器之前执行
}
}
4. PKCE认证服务器配置
配置OAuth2.1认证服务器:
@Configuration
@EnableWebFlux
public class OAuth2AuthorizationServerConfig {
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient mobileClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("mobile-app")
.clientSecret("{noop}mobile-secret") // 移动端使用public client
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // 公共客户端不需要密钥
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("myapp://oauth/callback") // 移动端回调地址
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(1))
.refreshTokenTimeToLive(Duration.ofDays(30))
.reuseRefreshTokens(false)
.build())
.oidcClientSettings(OidcClientSettings.builder()
.requirePkce(true) // 强制使用PKCE
.build())
.build();
return new InMemoryRegisteredClientRepository(mobileClient);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
}
5. PKCE认证端点
实现支持PKCE的认证端点:
@RestController
@RequestMapping("/oauth2")
@Slf4j
public class PkceAuthorizationController {
@Autowired
private OAuth2AuthorizationService authorizationService;
@Autowired
private RegisteredClientRepository registeredClientRepository;
/**
* 授权码端点,支持PKCE验证
*/
@GetMapping("/authorize")
public Mono<ResponseEntity<?>> authorize(
ServerWebExchange exchange,
@RequestParam String response_type,
@RequestParam String client_id,
@RequestParam String redirect_uri,
@RequestParam String code_challenge,
@RequestParam String code_challenge_method,
@RequestParam String state,
@RequestParam(required = false) String scope) {
ServerHttpRequest request = exchange.getRequest();
// 验证PKCE参数
if (!"S256".equals(code_challenge_method)) {
return Mono.just(ResponseEntity.badRequest()
.body("PKCE challenge method must be S256"));
}
if (code_challenge == null || code_challenge.length() < 43 || code_challenge.length() > 128) {
return Mono.just(ResponseEntity.badRequest()
.body("Invalid code challenge length"));
}
// 验证客户端
RegisteredClient client = registeredClientRepository.findByClientId(client_id);
if (client == null) {
return Mono.just(ResponseEntity.badRequest()
.body("Invalid client"));
}
// 检查客户端是否配置了PKCE
if (!client.getOidcClientSettings().isRequirePkce()) {
return Mono.just(ResponseEntity.badRequest()
.body("PKCE is required for this client"));
}
// 这里应该实现用户认证逻辑
// 实际项目中需要重定向到登录页面或返回授权页面
return Mono.just(ResponseEntity.ok()
.header("Location", redirect_uri + "?code=AUTH_CODE&state=" + state)
.build());
}
/**
* 令牌端点,验证PKCE
*/
@PostMapping("/token")
public Mono<ResponseEntity<OAuth2TokenResponse>> token(
ServerWebExchange exchange,
@RequestParam String grant_type,
@RequestParam String code,
@RequestParam String redirect_uri,
@RequestParam String code_verifier,
@RequestParam String client_id) {
if (!"authorization_code".equals(grant_type)) {
return Mono.just(ResponseEntity.badRequest()
.body(OAuth2TokenResponse.error("unsupported_grant_type")));
}
// 验证code_verifier
if (code_verifier == null || code_verifier.length() < 43 || code_verifier.length() > 128) {
return Mono.just(ResponseEntity.badRequest()
.body(OAuth2TokenResponse.error("invalid_request")
.errorDescription("Invalid code verifier")));
}
// 重建code challenge并验证
try {
String expectedCodeChallenge = PkceUtils.generateCodeChallenge(code_verifier);
String storedCodeChallenge = getCodeChallengeFromAuthCode(code); // 从存储中获取原始challenge
if (!expectedCodeChallenge.equals(storedCodeChallenge)) {
return Mono.just(ResponseEntity.badRequest()
.body(OAuth2TokenResponse.error("invalid_grant")
.errorDescription("PKCE verification failed")));
}
} catch (Exception e) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(OAuth2TokenResponse.error("server_error")));
}
// 生成令牌
return generateTokens(client_id, redirect_uri)
.map(ResponseEntity.ok()::body)
.switchIfEmpty(Mono.just(ResponseEntity.badRequest()
.body(OAuth2TokenResponse.error("invalid_grant"))));
}
private Mono<OAuth2TokenResponse> generateTokens(String clientId, String redirectUri) {
// 生成访问令牌和刷新令牌
String accessToken = generateAccessToken();
String refreshToken = generateRefreshToken();
return Mono.just(OAuth2TokenResponse.withToken(accessToken)
.tokenType(OAuth2TokenType.BEARER)
.expiresIn(Duration.ofHours(1).getSeconds())
.refreshToken(refreshToken)
.scopes(Set.of("read", "write"))
.build());
}
private String generateAccessToken() {
// 生成JWT访问令牌
return "ACCESS_TOKEN_" + UUID.randomUUID();
}
private String generateRefreshToken() {
// 生成刷新令牌
return "REFRESH_TOKEN_" + UUID.randomUUID();
}
private String getCodeChallengeFromAuthCode(String authCode) {
// 从存储中获取授权码对应的code challenge
// 实际实现中需要从数据库或缓存中查询
return "STORED_CHALLENGE";
}
}
6. 移动端认证流程
移动端App的认证流程:
// 移动端认证示例(伪代码)
public class MobileAuthenticator {
public void authenticate() {
// 1. 生成PKCE参数
String codeVerifier = PkceUtils.generateCodeVerifier();
String codeChallenge = PkceUtils.generateCodeChallenge(codeVerifier);
// 2. 构造授权请求URL
String authUrl = "http://auth-server/oauth2/authorize?" +
"response_type=code&" +
"client_id=mobile-app&" +
"redirect_uri=myapp://oauth/callback&" +
"code_challenge=" + codeChallenge + "&" +
"code_challenge_method=S256&" +
"state=" + generateState();
// 3. 打开浏览器进行授权
openBrowser(authUrl);
// 4. 接收授权码回调
// 在回调中使用codeVerifier交换令牌
handleAuthorizationCode(codeVerifier);
}
private void handleAuthorizationCode(String codeVerifier) {
// 使用授权码和code_verifier获取令牌
String tokenUrl = "http://auth-server/oauth2/token";
// POST请求,包含code_verifier
Map<String, String> params = new HashMap<>();
params.put("grant_type", "authorization_code");
params.put("code", receivedAuthCode);
params.put("redirect_uri", "myapp://oauth/callback");
params.put("client_id", "mobile-app");
params.put("code_verifier", codeVerifier); // 关键:提供code_verifier
// 发送请求获取令牌
// ...
}
}
7. 安全配置
配置额外的安全措施:
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange(exchanges -> exchanges
.pathMatchers("/oauth2/**").permitAll()
.pathMatchers("/actuator/**").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
)
.csrf(csrf -> csrf.disable()); // 移动端通常禁用CSRF
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("http://auth-server/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
}
}
8. Token管理
实现Token的存储和管理:
@Service
@Slf4j
public class TokenManager {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 存储访问令牌
*/
public void storeAccessToken(String token, OAuth2AuthenticatedPrincipal principal, String refreshToken) {
String key = "access_token:" + token;
TokenInfo tokenInfo = new TokenInfo();
tokenInfo.setPrincipal(principal);
tokenInfo.setRefreshToken(refreshToken);
tokenInfo.setCreateTime(System.currentTimeMillis());
redisTemplate.opsForValue().set(key, tokenInfo, Duration.ofHours(1));
}
/**
* 验证访问令牌
*/
public boolean validateAccessToken(String token) {
String key = "access_token:" + token;
return redisTemplate.hasKey(key);
}
/**
* 存储刷新令牌
*/
public void storeRefreshToken(String refreshToken, OAuth2AuthenticatedPrincipal principal) {
String key = "refresh_token:" + refreshToken;
TokenInfo tokenInfo = new TokenInfo();
tokenInfo.setPrincipal(principal);
tokenInfo.setCreateTime(System.currentTimeMillis());
redisTemplate.opsForValue().set(key, tokenInfo, Duration.ofDays(30));
}
/**
* 使用刷新令牌获取新访问令牌
*/
public OAuth2TokenResponse refreshToken(String refreshToken) {
String key = "refresh_token:" + refreshToken;
TokenInfo tokenInfo = (TokenInfo) redisTemplate.opsForValue().get(key);
if (tokenInfo == null) {
return OAuth2TokenResponse.error("invalid_grant");
}
// 生成新的访问令牌
String newAccessToken = generateNewAccessToken(tokenInfo.getPrincipal());
// 更新存储
storeAccessToken(newAccessToken, tokenInfo.getPrincipal(), refreshToken);
return OAuth2TokenResponse.withToken(newAccessToken)
.tokenType(OAuth2TokenType.BEARER)
.expiresIn(Duration.ofHours(1).getSeconds())
.build();
}
}
优势分析
相比传统的OAuth2.0,这种方案的优势明显:
- 安全性更高:PKCE机制防止授权码劫持
- 适用于移动端:无需存储客户端密钥
- 统一管理:网关层统一处理认证逻辑
- 标准化:遵循OAuth2.1标准
- 可扩展性:支持多种认证方式
注意事项
- Code Verifier长度:确保足够长以保证安全性
- Token存储:合理设置Token过期时间
- HTTPS:所有通信必须使用HTTPS
- 状态参数:防止CSRF攻击
- 监控告警:监控异常认证行为
总结
通过Spring Cloud Gateway + OAuth2.1 + PKCE的技术组合,我们可以构建一个安全可靠的移动端认证方案。这不仅能保护用户数据安全,还能为移动端App提供流畅的认证体验。
在实际项目中,建议根据具体业务需求调整配置参数,并建立完善的安全监控机制。
服务端技术精选,专注分享后端开发实战技术,助力你的技术成长!
更多技术文章请访问:www.jiangyi.space
标题:Spring Cloud Gateway + OAuth2.1 + PKCE:安全对接移动端 App,防止 Token 泄露
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/13/1768453930124.html
0 评论