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构建一个安全的移动端认证方案。

核心思路是:

  1. PKCE机制:防止授权码被劫持
  2. 网关统一认证:在网关层处理认证逻辑
  3. Token安全传输:确保令牌安全分发
  4. 动态密钥管理:避免静态密钥风险

技术选型

  • 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,这种方案的优势明显:

  1. 安全性更高:PKCE机制防止授权码劫持
  2. 适用于移动端:无需存储客户端密钥
  3. 统一管理:网关层统一处理认证逻辑
  4. 标准化:遵循OAuth2.1标准
  5. 可扩展性:支持多种认证方式

注意事项

  1. Code Verifier长度:确保足够长以保证安全性
  2. Token存储:合理设置Token过期时间
  3. HTTPS:所有通信必须使用HTTPS
  4. 状态参数:防止CSRF攻击
  5. 监控告警:监控异常认证行为

总结

通过Spring Cloud Gateway + OAuth2.1 + PKCE的技术组合,我们可以构建一个安全可靠的移动端认证方案。这不仅能保护用户数据安全,还能为移动端App提供流畅的认证体验。

在实际项目中,建议根据具体业务需求调整配置参数,并建立完善的安全监控机制。


服务端技术精选,专注分享后端开发实战技术,助力你的技术成长!

更多技术文章请访问:www.jiangyi.space


标题:Spring Cloud Gateway + OAuth2.1 + PKCE:安全对接移动端 App,防止 Token 泄露
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/01/13/1768453930124.html

    0 评论
avatar