OAuth2 + JWT 微服务认证方案深度解析

OAuth2 + JWT 微服务认证方案深度解析

一、问题背景

微服务架构下,认证授权面临多重挑战:

  • 无状态认证:服务间共享 session 困难
  • Token 安全:如何防止 Token 被伪造或篡改
  • 权限控制:细粒度的接口权限管理
  • 跨域问题:网关统一认证后如何传递用户信息

OAuth2 + JWT 是目前主流的微服务认证方案,结合网关统一鉴权,可以构建安全可靠的身份认证体系。

二、OAuth2 四种授权模式

2.1 授权码模式

最安全的授权模式,适合有后端的应用。
资源服务器 客户端应用 认证服务器 浏览器 用户 资源服务器 客户端应用 认证服务器 浏览器 用户 访问受保护资源 重定向到授权页面 请求授权 显示授权确认页 用户确认授权 返回授权码 传递授权码 用授权码换 Token 返回 Access Token 用 Token 请求资源 返回资源

2.2 密码凭证模式

适用于受信任的第一方应用。
认证服务器 受信任应用 用户 认证服务器 受信任应用 用户 输入用户名密码 用密码换取 Token 返回 Access Token 登录成功

2.3 客户端凭证模式

适用于服务间调用,不涉及用户。
服务 B 认证服务器 服务 A 服务 B 认证服务器 服务 A 用客户端凭证换取 Token 返回 Access Token 用 Token 调用接口 返回资源

2.4 简化模式

不返回 Refresh Token,适合纯前端应用。

三、JWT 结构解析

3.1 JWT 组成

复制代码
Header.Payload.Signature
json 复制代码
// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622,
  "roles": ["admin", "user"]
}

// Signature
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

3.2 JJWT 解析示例

java 复制代码
public class JwtUtils {
    
    private final SecretKey secretKey;
    
    public Claims parseToken(String token) {
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
    
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
    
    public boolean isTokenExpired(String token) {
        Claims claims = parseToken(token);
        return claims.getExpiration().before(new Date());
    }
}

3.3 JWT vs Session

维度 JWT Session
存储位置 客户端 服务端
扩展性 无状态,易扩展 需要 Redis 共享
安全性 签名防篡改 Cookie HttpOnly
失效机制 无法主动失效 可立即失效
体积 较小 较小
适用场景 跨域、微服务 单体、有状态

四、Spring Security OAuth2

4.1 Resource Server 配置

java 复制代码
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated();
    }
}

4.2 JWT 配置

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public JwtDecoder jwtDecoder() {
        return JwtDecoders.fromIssuerLocation(
            "http://auth-server/oauth2/token"
        );
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthorityConverter grantedAuthorityConverter = 
            new JwtGrantedAuthorityConverter();
        grantedAuthorityConverter.setAuthoritiesClaimName("roles");
        grantedAuthorityConverter.setAuthorityPrefix("ROLE_");
        
        JwtAuthenticationConverter jwtAuthenticationConverter = 
            new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
            grantedAuthorityConverter
        );
        
        return jwtAuthenticationConverter;
    }
}

4.3 安全过滤器链

JWT 验证
Security Filter Chain
WebSecurity
SecurityContext
Authentication
Authorization
JwtDecoder
JwtParser
Claims

五、Token 校验流程

5.1 网关统一鉴权

业务服务 认证服务器 API 网关 客户端 业务服务 认证服务器 API 网关 客户端 alt [本地校验] [远程校验] 请求 /api/user {Token} 提取 Token JWT 签名验证 过期检查 Claims 提取 校验 Token 有效性 返回用户信息 注入用户信息到 Header 转发请求 {X-User-Id, X-User-Roles} 权限校验 返回资源 返回响应

5.2 全局过滤器实现

java 复制代码
@Component
public class JwtAuthenticationFilter implements GlobalFilter {
    
    private final JwtUtils jwtUtils;
    private final AntPathMatcher pathMatcher = new AntPathMatcher();
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, 
                             GatewayFilterChain chain) {
        
        String path = exchange.getRequest().getPath().value();
        
        // 跳过白名单路径
        if (isWhiteListed(path)) {
            return chain.filter(exchange);
        }
        
        String token = extractToken(exchange);
        
        if (StringUtils.isEmpty(token)) {
            return unauthorized(exchange);
        }
        
        try {
            Claims claims = jwtUtils.parseToken(token);
            
            // 验证过期
            if (jwtUtils.isTokenExpired(token)) {
                return unauthorized(exchange);
            }
            
            // 注入用户信息到 Header
            ServerHttpRequest modifiedRequest = exchange.getRequest()
                .mutate()
                .header("X-User-Id", claims.getSubject())
                .header("X-User-Roles", 
                    String.join(",", claims.get("roles", List.class)))
                .build();
            
            return chain.filter(
                exchange.mutate().request(modifiedRequest).build()
            );
            
        } catch (Exception e) {
            return unauthorized(exchange);
        }
    }
    
    private String extractToken(ServerWebExchange exchange) {
        String bearerToken = exchange.getRequest()
            .getHeaders().getFirst("Authorization");
        
        if (StringUtils.hasText(bearerToken) 
            && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
    
    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(
            HttpStatus.UNAUTHORIZED
        );
        return exchange.getResponse().setComplete();
    }
}

六、微服务间认证

6.1 Feign 传递 Token

java 复制代码
@Configuration
public class FeignConfig {
    
    @Bean
    public RequestInterceptor requestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                // 从当前请求上下文获取 Token
                ServletRequestAttributes attributes = 
                    (ServletRequestAttributes) RequestContextHolder
                        .getRequestAttributes();
                
                if (attributes != null) {
                    String token = attributes.getRequest()
                        .getHeader("Authorization");
                    
                    if (StringUtils.hasText(token)) {
                        template.header("Authorization", token);
                    }
                }
            }
        };
    }
}

6.2 服务间调用安全

java 复制代码
@Configuration
public class ServiceAuthConfig {
    
    @Bean
    public Feign.Builder serviceAuthFeignBuilder() {
        return Feign.builder()
            .requestInterceptor(new ServiceTokenInterceptor())
            .errorDecoder(new ServiceAuthErrorDecoder());
    }
}

public class ServiceTokenInterceptor implements RequestInterceptor {
    
    @Override
    public void apply(RequestTemplate template) {
        // 服务间调用使用客户端凭证模式
        String token = obtainServiceToken();
        template.header("Authorization", "Bearer " + token);
    }
}

七、Token 刷新机制

7.1 Refresh Token 流程

认证服务器 API 网关 客户端 认证服务器 API 网关 客户端 alt [Token 即将过期] 请求 /api/resource {Access Token} Token 过期检查 返回 401 + refresh_required POST /auth/refresh {Refresh Token} 用 Refresh Token 换取新 Token 返回新 Access Token + 新 Refresh Token 返回新 Token

7.2 Token 刷新实现

java 复制代码
@Component
public class TokenRefreshService {
    
    private final JwtUtils jwtUtils;
    private final RedisTemplate<String, String> redisTemplate;
    
    public AuthToken refreshToken(String refreshToken) {
        // 验证 Refresh Token
        Claims claims = jwtUtils.parseToken(refreshToken);
        
        // 检查 Token 类型
        String tokenType = claims.get("token_type", String.class);
        if (!"refresh".equals(tokenType)) {
            throw new InvalidTokenException("Invalid token type");
        }
        
        // 生成新的 Access Token
        String newAccessToken = jwtUtils.generateAccessToken(
            claims.getSubject(),
            claims.get("roles", List.class)
        );
        
        // 生成新的 Refresh Token
        String newRefreshToken = jwtUtils.generateRefreshToken(
            claims.getSubject()
        );
        
        // 旧 Refresh Token 加入黑名单
        redisTemplate.opsForValue().set(
            "blacklist:refresh:" + refreshToken,
            "1",
            Duration.ofDays(7)
        );
        
        return new AuthToken(newAccessToken, newRefreshToken);
    }
}

7.3 Token 黑名单机制

java 复制代码
@Component
public class TokenBlacklistService {
    
    private final RedisTemplate<String, String> redisTemplate;
    
    public void blacklistToken(String token, long expirationSeconds) {
        redisTemplate.opsForValue().set(
            "blacklist:token:" + token,
            "1",
            Duration.ofSeconds(expirationSeconds)
        );
    }
    
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey("blacklist:token:" + token)
        );
    }
}

八、实战演示

8.1 完整的认证服务

java 复制代码
@RestController
@RequestMapping("/auth")
public class AuthController {
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @Autowired
    private UserService userService;
    
    @PostMapping("/login")
    public AuthToken login(@RequestBody LoginRequest request) {
        // 验证用户
        User user = userService.authenticate(
            request.getUsername(), 
            request.getPassword()
        );
        
        // 生成 Token
        String accessToken = jwtUtils.generateAccessToken(
            user.getId(),
            user.getRoles()
        );
        
        String refreshToken = jwtUtils.generateRefreshToken(
            user.getId()
        );
        
        return new AuthToken(accessToken, refreshToken);
    }
    
    @PostMapping("/refresh")
    public AuthToken refresh(@RequestBody RefreshRequest request) {
        // 验证 Refresh Token
        return tokenRefreshService.refreshToken(
            request.getRefreshToken()
        );
    }
    
    @PostMapping("/logout")
    public void logout(HttpServletRequest request) {
        String token = extractToken(request);
        long expiration = jwtUtils.getExpiration(token);
        
        // 加入黑名单
        tokenBlacklistService.blacklistToken(token, expiration);
    }
}

8.2 网关路由配置

yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        - id: auth-service
          uri: lb://auth-service
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1
        
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix=1
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200

8.3 网关全局安全配置

java 复制代码
@Configuration
public class GatewaySecurityConfig {
    
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(
            ServerHttpSecurity http) {
        
        return http
            .csrf().disable()
            .httpBasic().disable()
            .formLogin().disable()
            .authorizeExchange()
                .pathMatchers("/auth/**").permitAll()
                .pathMatchers("/actuator/**").permitAll()
                .pathMatchers("/api/public/**").permitAll()
                .anyExchange().authenticated()
                .and()
            .build();
    }
}

九、避坑指南

坑 1:JWT 存储安全

问题:Token 被 XSS 攻击窃取

解决

javascript 复制代码
// 使用 HttpOnly Cookie 存储 Refresh Token
// Access Token 存储在内存中
const tokenStore = {
    accessToken: null,
    setAccessToken(token) {
        this.accessToken = token;
    }
};

坑 2:Token 泄露风险

问题:日志中打印 Token

解决

java 复制代码
// 过滤器中清除敏感信息
exchange.getLog().addAttribute("Token", "***");

坑 3:跨域配置冲突

问题:CORS 配置与 Security 冲突

解决

java 复制代码
@Configuration
public class CorsConfig {
    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.setAllowCredentials(false);
        
        UrlBasedCorsConfigurationSource source = 
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        
        return new CorsWebFilter(source);
    }
}

坑 4:分布式 Session 问题

问题:多实例部署时用户状态不一致

解决:使用 Token 而非 Session,或使用分布式 Session 存储

坑 5:Token 续期策略

问题:频繁刷新 Token 影响体验

解决:采用"滑动窗口"续期

java 复制代码
// Access Token 剩余有效期 < 30 分钟时刷新
if (claims.getExpiration().getTime() - System.currentTimeMillis() 
    < 30 * 60 * 1000) {
    // 自动刷新
}

十、JWK Set URI 配置

10.1 JWK Set 概念

JWK Set 是一组 JSON Web Key,用于存储公钥信息,允许客户端验证 JWT 签名而无需共享密钥。

json 复制代码
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "key-id-1",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

10.2 Spring Security 配置

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # 方式1: Issuer URI (自动获取 JWK Set)
          issuer-uri: http://auth-server/oauth2
          
          # 方式2: 显式指定 JWK Set URI
          jwk-set-uri: http://auth-server/.well-known/jwks.json

10.3 自定义 JWK 配置

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public JwtDecoder jwtDecoder() {
        // 从 JWK Set URI 获取公钥
        return JwtDecoders.fromJwkSetUri(
            "http://auth-server/.well-known/jwks.json"
        );
    }
    
    @Bean
    public NimbusJwtDecoder jwtDecoder(JwkKeyAccessor jwkKeyAccessor) {
        return NimbusJwtDecoder.withJwkSetUri(
            "http://auth-server/.well-known/jwks.json"
        ).jwkSetUri(jwkKeyAccessor).build();
    }
}

10.4 JWK 缓存机制

java 复制代码
@Component
public class JwkCacheManager {
    
    private final Map<String, JwkCacheEntry> cache = new ConcurrentHashMap<>();
    
    public JWK getJwk(String kid) {
        JwkCacheEntry entry = cache.get(kid);
        
        if (entry == null || entry.isExpired()) {
            entry = fetchJwkFromServer(kid);
            cache.put(kid, entry);
        }
        
        return entry.getJwk();
    }
    
    private JwkCacheEntry fetchJwkFromServer(String kid) {
        // 从 JWK Set 获取指定 kid 的密钥
        return new JwkCacheEntry(jwk, Duration.ofHours(1));
    }
}

十一、PKCE 授权码模式

11.1 PKCE 流程

PKCE(Proof Key for Code Exchange)是 OAuth 2.0 的安全扩展,防止授权码被拦截。
User Auth Server Client App User Auth Server Client App 生成 code_verifier (随机字符串) 计算 code_challenge = BASE64URL(SHA256(code_verifier)) 授权请求 + code_challenge + method=S256 显示登录页面 用户登录 返回授权码 用 code + code_verifier 换 Token 验证 code_challenge 返回 Access Token

11.2 代码实现

java 复制代码
@Service
public class PkceAuthService {
    
    private static final SecureRandom secureRandom = new SecureRandom();
    
    /**
     * 生成 code_verifier
     */
    public String generateCodeVerifier() {
        byte[] buffer = new byte[32];
        secureRandom.nextBytes(buffer);
        return Base64.getUrlEncoder().withoutPadding()
            .encodeToString(buffer);
    }
    
    /**
     * 生成 code_challenge
     */
    public String generateCodeChallenge(String codeVerifier) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
            return Base64.getUrlEncoder().withoutPadding()
                .encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
    
    /**
     * 发起 PKCE 授权请求
     */
    public void authorize() {
        String codeVerifier = generateCodeVerifier();
        String codeChallenge = generateCodeChallenge(codeVerifier);
        
        // 保存 code_verifier 用于后续验证
        session.setAttribute("code_verifier", codeVerifier);
        
        // 构建授权 URL
        String authUrl = UriComponentsBuilder
            .fromHttpUrl("http://auth-server/oauth/authorize")
            .queryParam("response_type", "code")
            .queryParam("client_id", "client-id")
            .queryParam("redirect_uri", "http://localhost:8080/callback")
            .queryParam("scope", "read write")
            .queryParam("code_challenge", codeChallenge)
            .queryParam("code_challenge_method", "S256")
            .build()
            .toUriString();
        
        // 重定向到授权服务器
        return "redirect:" + authUrl;
    }
    
    /**
     * 用 code 换 Token
     */
    public AuthToken exchangeToken(String code) {
        String codeVerifier = session.getAttribute("code_verifier");
        
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("code", code);
        params.add("redirect_uri", "http://localhost:8080/callback");
        params.add("client_id", "client-id");
        params.add("code_verifier", codeVerifier);
        
        // 用 code_verifier 换取 Token
        return restTemplate.postForObject(
            "http://auth-server/oauth/token",
            params,
            AuthToken.class
        );
    }
}

11.3 Spring Security OAuth2 PKCE 支持

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(authorization -> authorization
                    .authorizationRequestRepository(
                        new HttpSessionAuthorizationRequestRepository()
                    )
                )
                .redirectionEndpoint(endpoint -> endpoint
                    .baseUri("/oauth2/callback/*")
                )
                .tokenEndpoint(token -> token
                    .accessTokenResponseClient(
                        OAuth2AccessTokenResponseClient
                            .withDefaults()
                    )
                )
            );
        
        return http.build();
    }
}

十二、Token 设备指纹绑定

12.1 指纹采集

java 复制代码
@Component
public class DeviceFingerprintService {
    
    public String generateFingerprint(HttpServletRequest request) {
        StringBuilder sb = new StringBuilder();
        
        // User Agent
        sb.append(request.getHeader("User-Agent"));
        sb.append("|");
        
        // Accept Language
        sb.append(request.getHeader("Accept-Language"));
        sb.append("|");
        
        // Client IP
        sb.append(getClientIp(request));
        sb.append("|");
        
        // Screen Resolution (前端传入)
        sb.append(request.getHeader("X-Screen-Resolution"));
        sb.append("|");
        
        // Timezone
        sb.append(request.getHeader("X-Timezone"));
        
        // 计算 Hash
        return sha256(sb.toString());
    }
    
    private String sha256(String input) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes());
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

12.2 Token 绑定验证

java 复制代码
@Component
public class TokenBindingValidator {
    
    @Autowired
    private DeviceFingerprintService fingerprintService;
    
    public boolean validateBinding(String token, HttpServletRequest request) {
        Claims claims = jwtUtils.parseToken(token);
        String tokenFingerprint = claims.get("fingerprint", String.class);
        String currentFingerprint = fingerprintService.generateFingerprint(request);
        
        // 允许小幅度的指纹变化(如浏览器更新)
        return similar(tokenFingerprint, currentFingerprint, 0.8);
    }
    
    private boolean similar(String a, String b, double threshold) {
        if (a == null || b == null) return false;
        
        // 使用 Jaro-Winkler 距离
        double distance = jaroWinklerSimilarity(a, b);
        return distance >= threshold;
    }
}

12.3 异常处理

java 复制代码
@Component
public class TokenBindingInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response,
                            Object handler) throws Exception {
        
        String token = extractToken(request);
        if (token != null) {
            if (!tokenBindingValidator.validateBinding(token, request)) {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.getWriter().write("{\"error\": \"device_mismatch\"}");
                return false;
            }
        }
        
        return true;
    }
}

十三、总结

OAuth2 + JWT 是微服务认证的最佳实践:

维度 内容
OAuth2 模式 授权码、密码、客户端凭证、简化模式
JWT 结构 Header、Payload、Signature
签名算法 HS256 对称、RS256 非对称
JWK Set 公钥分发、动态密钥轮换
PKCE 授权码安全扩展、防止拦截攻击
Spring Security ResourceServer + JWT 集成
网关鉴权 GlobalFilter 统一 Token 校验
服务间认证 Feign 拦截器传递 Token
Token 管理 刷新机制 + 黑名单 + 设备指纹
安全增强 设备绑定、异常检测

结合网关统一鉴权、服务间 Token 传递、完善的 Token 刷新机制,可以构建安全可靠的微服务身份认证体系。

相关推荐
日取其半万世不竭10 小时前
服务器自动备份方案:用 rsync + cron 实现异地增量备份
运维·服务器·php
艾莉丝努力练剑10 小时前
【Linux网络】Linux 网络编程入门:UDP Socket 编程(下)
linux·运维·服务器·网络·计算机网络·安全·udp
diangedan10 小时前
Android冻屏
android·java
qq_4523962316 小时前
第十五篇:《UI自动化中的稳定性优化:解决flaky tests的七种武器》
运维·ui·自动化
abcnull17 小时前
用javaparser做精准测试
java·ast·静态代码分析·精准测试·javaparser
wapicn9917 小时前
微服务架构下的数据核验设计,API接入最佳实践
微服务·云原生·架构
j_xxx404_17 小时前
Linux:静态链接与动态链接深度解析
linux·运维·服务器·c++·人工智能
叶小鸡17 小时前
Java 篇-项目实战-苍穹外卖-笔记汇总
java·开发语言·笔记
AI人工智能+电脑小能手17 小时前
【大白话说Java面试题】【Java基础篇】第22题:HashMap 和 HashSet 有哪些区别
java·开发语言·哈希算法·散列表·hash