1. 这不是“又一个OAuth2教程”,而是生产环境里真正跑得稳的JWT权限链路
我第一次在某电商中台项目里落地Spring Security OAuth2 + JWT时,信心满满地照着三篇热门博客搭完,结果压测刚到800 QPS,/oauth/token接口就开始503。日志里满屏InvalidClientException: Client secret not provided,可配置文件里明明写了client-secret。后来翻了三天源码才明白:Spring Security 5.7+默认启用了ClientRegistrationRepository的自动注册机制,而我们用的InMemoryClientDetailsService和新机制存在隐式冲突——它会把客户端密钥当成null处理。这种坑,文档里不会写,Stack Overflow上搜到的答案90%是过时的。
这个标题里的“完整示例”,不是指能跑通登录跳转就叫完整。它必须覆盖真实业务中绕不开的五个硬骨头:客户端凭证如何安全注入、JWT令牌如何携带用户角色与权限、刷新令牌如何防重放、资源服务器如何校验签名并解析上下文、以及最关键的——当OAuth2授权服务器和资源服务器分离部署时,公钥分发与轮换怎么不中断服务。市面上90%的所谓“完整教程”,连JWT签名算法选型都没讲清楚:为什么HS256在单体应用里够用,但微服务架构下必须切到RS256?为什么spring-security-oauth2-autoconfigure在Spring Boot 2.4之后被官方弃用,而你还在用它生成@EnableAuthorizationServer?这些不是细节,是系统上线前夜运维同事拍桌子问你的问题。
关键词里虽然没填,但标题本身已经锁定了四个不可回避的技术坐标:Spring Boot 2.7+(LTS版本)、Spring Security 5.7+(非旧版spring-security-oauth2)、JWT标准(RFC 7519)、以及OAuth2.0授权框架(RFC 6749)。这意味着所有代码必须基于spring-boot-starter-oauth2-authorization-server(官方新授权服务器模块)和spring-boot-starter-oauth2-resource-server(资源服务器模块)构建,而不是拼凑spring-security-jwt或第三方库。我见过太多团队因为混用新旧模块,在升级Spring Boot 3.x时整周无法发布——不是功能坏了,是JwtDecoder的Bean注册方式变了,旧配置直接被Spring容器忽略。
所以这篇不是教你怎么写@EnableWebSecurity,而是带你从零手写一个能在K8s集群里滚动更新、支持密钥轮换、令牌可审计、且能和前端Vue/React SPA无缝对接的权限中枢。源码我会拆解成四个独立模块:授权服务器(含动态客户端管理)、资源服务器(含细粒度方法级鉴权)、网关层(统一令牌解析与透传)、以及前端SDK轻量封装。每一段代码背后,都对应一个我在三个不同项目里踩过的坑。
提示:本文所有配置均基于Spring Boot 2.7.18 + Spring Security 5.7.11,不兼容Spring Boot 3.x(其模块命名与包路径已重构)。若你正使用Spring Boot 3.x,请先确认
spring-authorization-server1.2+版本是否满足需求,再决定是否迁移——这不是简单的版本号替换,而是整个安全模型的重设计。
2. 授权服务器:为什么不能只靠application.yml配出一个生产级Token端点
2.1 客户端注册必须代码化,而非全靠配置文件
很多教程把客户端信息全塞进application.yml:
spring: security: oauth2: authorization-server: client: web-app: registration: client-id: "web-app" client-secret: "secret123" client-authentication-methods: "client_secret_basic" scopes: "read,write" redirect-uris: "http://localhost:8080/login/oauth2/code/web-app"这在本地开发没问题,但一上生产就崩。原因有三:
第一,密钥硬编码风险:client-secret明文写死,CI/CD流水线日志可能泄露;
第二,动态扩展缺失:新接入一个小程序客户端,得改配置、重启服务,违背云原生“零停机”原则;
第三,权限粒度失控:所有客户端共享同一套scope,无法为IoT设备客户端限制仅允许device:status,也无法为管理后台开放admin:*。
正确做法是实现RegisteredClientRepository接口,用数据库持久化客户端元数据。我们用H2内存库做演示(生产环境请换MySQL/PostgreSQL):
@Component public class JdbcRegisteredClientRepository implements RegisteredClientRepository { private final JdbcTemplate jdbcTemplate; public JdbcRegisteredClientRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void save(RegisteredClient registeredClient) { String sql = "INSERT INTO registered_client (id, client_id, client_id_issued_at, " + "client_secret, client_authentication_methods, authorization_grant_types, " + "redirect_uris, scopes, client_settings, token_settings) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; jdbcTemplate.update(sql, registeredClient.getId(), registeredClient.getClientId(), registeredClient.getClientIdIssuedAt(), registeredClient.getClientSecret(), // 注意:此处应为BCrypt加密后的密文 registeredClient.getClientAuthenticationMethods().toString(), registeredClient.getAuthorizationGrantTypes().toString(), registeredClient.getRedirectUris().toString(), registeredClient.getScopes().toString(), registeredClient.getClientSettings().toString(), registeredClient.getTokenSettings().toString()); } @Override public RegisteredClient findById(String id) { String sql = "SELECT * FROM registered_client WHERE id = ?"; return jdbcTemplate.queryForObject(sql, new RegisteredClientRowMapper(), id); } // ... 其他方法省略,重点看RegisteredClientRowMapper }关键在RegisteredClientRowMapper里对client_secret的处理:
private static class RegisteredClientRowMapper implements RowMapper<RegisteredClient> { @Override public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException { String clientId = rs.getString("client_id"); String clientSecret = rs.getString("client_secret"); // 生产环境必须校验密文:若clientSecret不为空,需用BCrypt.matches()验证 // 此处简化:直接构造RegisteredClient,实际应调用PasswordEncoder return RegisteredClient.withId(rs.getString("id")) .clientId(clientId) .clientIdIssuedAt(rs.getTimestamp("client_id_issued_at").toInstant()) .clientSecret(clientSecret) // 注意:这是加密后的密文,非明文 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://localhost:8080/login/oauth2/code/" + clientId) .scope("read") .scope("write") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) .refreshTokenTimeToLive(Duration.ofDays(30)) .reuseRefreshTokens(false) // 关键:禁用refresh_token复用,防劫持 .build()) .build(); } }注意:
reuseRefreshTokens(false)是硬性要求。若设为true,同一refresh_token可多次换取新access_token,一旦泄露,攻击者可无限续期——这是OWASP Top 10明确标注的高危漏洞。很多教程默认开启,只因没读RFC 6749第1.5节关于refresh_token安全性的强制规范。
2.2 JWT签名必须用RS256,且密钥轮换要无感
HS256(HMAC-SHA256)用同一个密钥签名和验签,简单高效。但它有个致命缺陷:授权服务器和资源服务器必须共享同一份密钥。在微服务架构下,这意味着要么把密钥硬编码进每个服务的配置,要么用配置中心统一推送——但密钥轮换时,所有服务必须同时重启,否则新旧密钥并存期间会出现大量InvalidSignatureException。
RS256(RSA-SHA256)用非对称密钥:授权服务器用私钥签名,所有资源服务器用公钥验签。公钥可公开分发,无需保密。轮换时只需更新公钥,服务无需重启。Spring Security官方推荐此方案。
生成密钥对(生产环境请用硬件安全模块HSM):
# 生成2048位RSA密钥对 openssl genrsa -out jwt-private-key.pem 2048 openssl rsa -in jwt-private-key.pem -pubout -out jwt-public-key.pem在授权服务器中注入JwtEncoder:
@Bean public JwtEncoder jwtEncoder() { // 从classpath读取私钥文件 KeyPair keyPair = getRsaKeyPair(); return new NimbusJwtEncoder(JWKSet.load(new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) .privateKey((RSAPrivateKey) keyPair.getPrivate()) .build())); } private KeyPair getRsaKeyPair() { try { ClassPathResource resource = new ClassPathResource("jwt-private-key.pem"); String privateKeyPem = Files.readString(resource.getFile().toPath()); String privateKeyContent = privateKeyPem .replace("-----BEGIN RSA PRIVATE KEY-----", "") .replace("-----END RSA PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] encoded = Base64.getDecoder().decode(privateKeyContent); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); RSAPrivateKey privateKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec); // 公钥从私钥推导(生产环境建议分开加载) RSAPublicKey publicKey = (RSAPublicKey) privateKey.getPublic(); return new KeyPair(publicKey, privateKey); } catch (Exception e) { throw new RuntimeException("Failed to load RSA key pair", e); } }资源服务器验签时,不再需要私钥,只需公钥:
@Bean public JwtDecoder jwtDecoder() { try { ClassPathResource resource = new ClassPathResource("jwt-public-key.pem"); String publicKeyPem = Files.readString(resource.getFile().toPath()); String publicKeyContent = publicKeyPem .replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replaceAll("\\s", ""); byte[] encoded = Base64.getDecoder().decode(publicKeyContent); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); return NimbusJwtDecoder.withPublicKey(publicKey).build(); } catch (Exception e) { throw new RuntimeException("Failed to load RSA public key", e); } }密钥轮换怎么做?答案是:在公钥文件名后加版本号,并通过HTTP端点暴露。例如:
/oauth2/jwks/1返回v1公钥/oauth2/jwks/2返回v2公钥
资源服务器启动时,从/oauth2/jwks/1拉取公钥;轮换时,运维更新/oauth2/jwks/2,然后修改配置指向新端点——服务无需重启,旧令牌仍可用至过期,新令牌自动用新密钥签名。
2.3 刷新令牌必须绑定设备指纹,且单次有效
OAuth2规范要求refresh_token必须“一次一用”(one-time use)。但Spring Security默认实现不校验此规则,导致一个refresh_token被多次提交仍能成功换token。攻击者截获后可无限续期。
解决方案:在颁发refresh_token时,将其哈希值存入Redis,并设置与refresh_token相同的过期时间:
@Component public class CustomTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> { private final RedisTemplate<String, String> redisTemplate; public CustomTokenCustomizer(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public void customize(JwtEncodingContext context) { if (OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) { // 生成refresh_token的SHA256哈希,作为Redis Key String refreshTokenValue = context.getPrincipal().getName(); // 实际为refresh_token字符串 String hashKey = DigestUtils.sha256Hex(refreshTokenValue); // 存入Redis,过期时间=refresh_token TTL redisTemplate.opsForValue().set( "rt:" + hashKey, "used", Duration.ofHours(1) // 与refresh_token TTL一致 ); } } }在RefreshTokenAuthenticationProvider中校验:
public class ValidatingRefreshTokenAuthenticationProvider extends RefreshTokenAuthenticationProvider { private final RedisTemplate<String, String> redisTemplate; public ValidatingRefreshTokenAuthenticationProvider( OAuth2RefreshTokenService refreshTokenService, RedisTemplate<String, String> redisTemplate) { super(refreshTokenService); this.redisTemplate = redisTemplate; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2RefreshTokenAuthenticationToken token = (OAuth2RefreshTokenAuthenticationToken) authentication; String refreshTokenValue = token.getRefreshToken().getTokenValue(); String hashKey = DigestUtils.sha256Hex(refreshTokenValue); // 检查Redis中是否存在该哈希 String usedFlag = redisTemplate.opsForValue().get("rt:" + hashKey); if ("used".equals(usedFlag)) { throw new InvalidGrantException("Refresh token has been used"); } // 校验通过,标记为已使用 redisTemplate.opsForValue().set( "rt:" + hashKey, "used", Duration.ofHours(1) ); return super.authenticate(authentication); } }踩坑实录:某金融项目曾因未做此校验,导致内部员工用抓包工具反复提交同一refresh_token,将一个临时测试账号的权限续期了三个月,直到安全审计发现异常登录频次才暴露。记住:RFC 6749第10.12节明确要求“refresh tokens MUST be single-use”。
3. 资源服务器:JWT解析不是终点,上下文注入才是权限控制的起点
3.1 不要只依赖@PreAuthorize,方法级鉴权必须结合JWT Claims
@PreAuthorize("hasRole('ADMIN')")看似简洁,但它依赖Spring Security的Authentication对象中getAuthorities()返回的角色列表。而JWT默认只把scope映射为GrantedAuthority,角色(role)需手动注入。更糟的是,若前端在JWT中写"roles": ["USER","PREMIUM"],但Spring Security默认不解析此字段,hasRole('PREMIUM')永远为false。
正确做法:自定义JwtAuthenticationConverter,从JWT Claims中提取roles数组,并转换为SimpleGrantedAuthority:
@Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { // 从JWT中提取roles数组 List<String> roles = jwt.getClaimAsStringList("roles"); if (roles == null || roles.isEmpty()) { return Collections.emptyList(); } // 转换为ROLE_*格式的GrantedAuthority return roles.stream() .map(role -> "ROLE_" + role.toUpperCase()) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); }); return converter; }此时@PreAuthorize("hasRole('PREMIUM')")才能生效。但更推荐用@PreAuthorize("hasAuthority('ROLE_PREMIUM')"),语义更精确。
然而,仅靠角色还不够。真实业务常需属性级鉴权:例如,用户只能编辑自己创建的文章。这时需在JWT中嵌入用户ID,并在方法参数中注入:
@GetMapping("/articles/{id}") @PreAuthorize("@articleService.isOwner(authentication, #id)") public Article getArticle(@PathVariable Long id) { return articleService.findById(id); }articleService.isOwner()实现:
@Service public class ArticleService { public boolean isOwner(Authentication auth, Long articleId) { // 从Authentication中提取JWT Jwt jwt = (Jwt) auth.getPrincipal(); Long userId = jwt.getClaimAsLong("user_id"); // JWT中必须有user_id字段 Article article = articleRepository.findById(articleId).orElse(null); return article != null && Objects.equals(article.getAuthorId(), userId); } }关键点:JWT生成时必须包含user_id:
// 在AuthorizationServerConfig中 @Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() { return context -> { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { Authentication principal = context.getPrincipal(); // 获取用户ID(假设UserDetails实现类为CustomUserDetails) CustomUserDetails userDetails = (CustomUserDetails) principal.getPrincipal(); context.getClaims().claim("user_id", userDetails.getId()); context.getClaims().claim("roles", userDetails.getRoles()); // 同时注入roles } }; }3.2 全局异常处理器必须区分JWT校验失败类型
当JWT过期、签名无效、或缺少必要claims时,Spring Security抛出不同异常,但默认返回500 Internal Server Error,前端无法区分是“请重新登录”还是“网络错误”。必须捕获并返回语义化状态码:
@RestControllerAdvice public class SecurityExceptionHandler { @ExceptionHandler(JwtValidationException.class) public ResponseEntity<ErrorResponse> handleJwtValidationException(JwtValidationException e) { String message = e.getMessage(); if (message.contains("Expired")) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("TOKEN_EXPIRED", "Access token expired")); } else if (message.contains("Invalid signature")) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("INVALID_SIGNATURE", "Token signature invalid")); } else if (message.contains("Missing claim")) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("MISSING_CLAIM", "Required claim missing in token")); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("TOKEN_INVALID", "Invalid access token")); } @ExceptionHandler(InvalidBearerTokenException.class) public ResponseEntity<ErrorResponse> handleInvalidBearerToken(InvalidBearerTokenException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("INVALID_TOKEN_FORMAT", "Bearer token format invalid")); } }ErrorResponse结构:
public class ErrorResponse { private String code; private String message; private Instant timestamp; public ErrorResponse(String code, String message) { this.code = code; this.message = message; this.timestamp = Instant.now(); } // getter/setter省略 }前端收到TOKEN_EXPIRED时,可自动跳转登录页;收到INVALID_SIGNATURE则提示“账号已在其他设备登录”,触发强制登出。
3.3 方法级鉴权性能优化:避免每次请求都查DB
@PreAuthorize("@userService.hasPermission(authentication, 'article:delete')")这类表达式,若hasPermission方法内部查询数据库,会导致每次API调用都产生额外SQL。对于QPS 1000+的服务,这会成为瓶颈。
优化方案:将用户权限缓存在JWT中。在用户登录成功后,一次性查询其所有权限(如["article:read","article:write","user:profile"]),写入JWT的permissions字段:
// 登录成功后生成JWT时 context.getClaims().claim("permissions", userService.findPermissionsByUserId(userId));然后自定义PermissionEvaluator:
@Component public class JwtPermissionEvaluator implements PermissionEvaluator { @Override public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) { if (!(auth.getPrincipal() instanceof Jwt)) { return false; } Jwt jwt = (Jwt) auth.getPrincipal(); List<String> permissions = jwt.getClaimAsStringList("permissions"); if (permissions == null) { return false; } return permissions.contains(permission.toString()); } @Override public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) { return false; // 本例不处理targetId场景 } }启用此评估器:
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class MethodSecurityConfig { @Bean public MethodSecurityExpressionHandler expressionHandler() { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); handler.setPermissionEvaluator(new JwtPermissionEvaluator()); return handler; } }现在@PreAuthorize("hasPermission('article:delete')")完全在内存中完成,无DB查询。权限变更时,只需让用户重新登录获取新JWT——这比实时查DB快两个数量级。
4. 网关层:为什么API网关必须承担令牌解析与透传职责
4.1 网关是JWT的“第一道安检门”,不是可有可无的装饰
很多团队把网关当作流量转发器,认为“鉴权在各服务自己做就行”。这导致三个严重问题:
- 重复鉴权开销:每个微服务都要解析JWT、验签、查权限,CPU消耗翻N倍;
- 安全策略不一致:订单服务用HS256,用户服务用RS256,密钥管理混乱;
- 令牌透传丢失:前端传
Authorization: Bearer xxx,网关转发时不带此头,下游服务收不到token。
正确架构:网关统一解析JWT,验证通过后,将用户身份信息以标准头透传给下游。例如:
| 头名称 | 值示例 | 说明 |
|---|---|---|
X-User-ID | 12345 | 用户唯一ID,下游服务可直接用 |
X-User-Roles | USER,PREMIUM | 角色列表,逗号分隔 |
X-User-Permissions | article:read,article:write | 权限列表 |
Spring Cloud Gateway配置:
spring: cloud: gateway: routes: - id: article-service uri: lb://article-service predicates: - Path=/api/articles/** filters: - TokenRelay= # 启用OAuth2令牌透传(需配合ReactiveOAuth2AuthorizedClientManager) - name: RequestHeaderToRequestUri args: header: Authorization prefix: "Bearer "但TokenRelay过滤器只适用于WebFlux,且依赖ReactiveOAuth2AuthorizedClientManager。更可控的方式是自定义全局过滤器:
@Component public class JwtAuthenticationFilter implements GlobalFilter, Ordered { private final JwtDecoder jwtDecoder; public JwtAuthenticationFilter(JwtDecoder jwtDecoder) { this.jwtDecoder = jwtDecoder; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String authHeader = request.getHeaders().getFirst("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Jwt jwt = jwtDecoder.decode(token); // 解析用户信息 Long userId = jwt.getClaimAsLong("user_id"); List<String> roles = jwt.getClaimAsStringList("roles"); List<String> permissions = jwt.getClaimAsStringList("permissions"); // 构建新请求头 ServerHttpRequest.Builder builder = request.mutate(); builder.header("X-User-ID", String.valueOf(userId)); if (roles != null) { builder.header("X-User-Roles", String.join(",", roles)); } if (permissions != null) { builder.header("X-User-Permissions", String.join(",", permissions)); } return chain.filter(exchange.mutate().request(builder.build()).build()); } catch (BadJwtException e) { // JWT解析失败,返回401 ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); } } // 无token,放行(由下游服务鉴权) return chain.filter(exchange); } @Override public int getOrder() { return -100; // 优先级最高 } }下游服务(如article-service)不再解析JWT,直接读取头:
@RestController public class ArticleController { @GetMapping("/api/articles/{id}") public Article getArticle(@RequestHeader("X-User-ID") Long userId, @PathVariable Long id) { // 业务逻辑:根据X-User-ID做权限校验或数据过滤 return articleService.findByIdAndAuthorId(id, userId); } }4.2 网关必须实现令牌续期,解决前端静默刷新难题
SPA应用(Vue/React)面临经典问题:access_token 1小时过期,用户正在编辑长文,突然弹窗“登录已过期”。前端可拦截401,用refresh_token换新access_token,但需后端配合。
网关需提供/oauth2/refresh端点,接收refresh_token,调用授权服务器换取新access_token:
@RestController @RequestMapping("/oauth2") public class TokenRefreshController { private final WebClient webClient; // 配置为指向授权服务器 public TokenRefreshController(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder .baseUrl("http://auth-server:9000") // 授权服务器地址 .build(); } @PostMapping("/refresh") public Mono<Map<String, Object>> refreshAccessToken( @RequestBody Map<String, String> requestBody) { String refreshToken = requestBody.get("refresh_token"); return webClient.post() .uri("/oauth2/token") .header("Content-Type", "application/x-www-form-urlencoded") .bodyValue("grant_type=refresh_token&refresh_token=" + refreshToken) .retrieve() .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .onErrorResume(e -> Mono.just(Map.of( "error", "invalid_refresh_token", "error_description", "Refresh token is invalid or expired" ))); } }前端调用此端点,拿到新access_token后,更新本地存储,并重试原请求。网关层统一处理,避免每个服务都实现刷新逻辑。
4.3 网关日志必须记录JWT关键字段,用于安全审计
生产环境必须记录每次请求的JWT关键信息,以便追溯:谁、何时、访问了什么资源、权限是否匹配。网关是最佳埋点位置。
自定义日志过滤器:
@Component public class AuditLoggingFilter implements GlobalFilter, Ordered { private static final Logger logger = LoggerFactory.getLogger(AuditLoggingFilter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String authHeader = request.getHeaders().getFirst("Authorization"); String userId = request.getHeaders().getFirst("X-User-ID"); String path = request.getPath().toString(); // 记录审计日志 logger.info("AUDIT: method={} path={} user_id={} ip={} user_agent={}", request.getMethodValue(), path, userId != null ? userId : "ANONYMOUS", getClientIpAddress(exchange), request.getHeaders().getFirst("User-Agent")); return chain.filter(exchange); } private String getClientIpAddress(ServerWebExchange exchange) { HttpHeaders headers = exchange.getRequest().getHeaders(); String ip = headers.getFirst("X-Forwarded-For"); if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { return ip.split(",")[0].trim(); } return exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); } @Override public int getOrder() { return 0; // 在鉴权后执行 } }日志示例:
2023-10-05 14:22:33.123 INFO AUDIT: method=GET path=/api/articles/123 user_id=45678 ip=203.0.113.42 user_agent=Mozilla/5.0...此日志可接入ELK,设置告警规则:如“同一user_id 1分钟内访问/admin/*超10次”,立即通知安全团队。
5. 前端SDK:为什么JWT不能裸奔在浏览器里,必须封装安全层
5.1 Access Token绝不存localStorage,必须用httpOnly Cookie
几乎所有前端教程都教:“把JWT存localStorage,每次请求带上Authorization头”。这是最大安全误区。XSS攻击可轻易窃取localStorage中的token,而httpOnly Cookie无法被JS读取。
正确方案:授权服务器返回token时,用Set-Cookie头设置httpOnly Cookie:
// 在授权服务器的登录成功处理器中 public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { // 生成access_token和refresh_token String accessToken = generateAccessToken(successfulAuthentication); String refreshToken = generateRefreshToken(successfulAuthentication); // 设置httpOnly Cookie Cookie accessTokenCookie = new Cookie("access_token", accessToken); accessTokenCookie.setHttpOnly(true); accessTokenCookie.setSecure(true); // 仅HTTPS传输 accessTokenCookie.setPath("/"); accessTokenCookie.setMaxAge(3600); // 1小时 response.addCookie(accessTokenCookie); Cookie refreshTokenCookie = new Cookie("refresh_token", refreshToken); refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setSecure(true); refreshTokenCookie.setPath("/"); refreshTokenCookie.setMaxAge(2592000); // 30天 response.addCookie(refreshTokenCookie); } }前端发起请求时,浏览器自动携带Cookie,无需JS操作:
// Axios全局配置 axios.defaults.withCredentials = true; // 启用Cookie发送后端资源服务器从Cookie读取token:
@Component public class CookieJwtAuthenticationConverter implements Converter<ServerWebExchange, Mono<AbstractAuthenticationToken>> { private final JwtDecoder jwtDecoder; public CookieJwtAuthenticationConverter(JwtDecoder jwtDecoder) { this.jwtDecoder = jwtDecoder; } @Override public Mono<AbstractAuthenticationToken> convert(ServerWebExchange exchange) { String accessToken = getCookieValue(exchange, "access_token"); if (accessToken == null) { return Mono.empty(); } try { Jwt jwt = jwtDecoder.decode(accessToken); Collection<GrantedAuthority> authorities = extractAuthorities(jwt); return Mono.just(new JwtAuthenticationToken(jwt, authorities)); } catch (BadJwtException e) { return Mono.empty(); } } private String getCookieValue(ServerWebExchange exchange, String cookieName) { return exchange.getRequest().getCookies().getFirst(cookieName) .map(ServerCookie::getValue) .orElse(null); } }5.2 前端必须实现双Token自动续期,且防并发请求
当access_token即将过期(如剩余60秒),前端应静默用refresh_token换新token。但多个API请求同时触发续期,会导致refresh_token被多次使用而失效。
解决方案:用Promise缓存续期请求,确保同一时刻只有一个续期调用:
class TokenManager { private refreshPromise: Promise<void> | null = null; async refreshToken() { if (this.refreshPromise) { return this.refreshPromise; // 返回缓存的Promise } this.refreshPromise = this.doRefresh(); try { await this.refreshPromise; } finally { this.refreshPromise = null; // 无论成功失败都清空 } } private async doRefresh() { const refreshToken = getCookie('refresh_token'); const response = await fetch('/oauth2/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }) }); if (!response.ok) { throw new Error('Refresh failed'); } const data = await response.json(); // 更新Cookie document.cookie = `access_token=${data.access_token}; HttpOnly; Secure; Path=/`; document.cookie = `refresh_token=${data.refresh_token}; HttpOnly; Secure; Path=/`; } }5.3 前端路由守卫必须校验JWT过期时间,而非只看是否存在
很多Vue Router守卫这样写:
router.beforeEach((to, from, next) => { if (!getCookie('access_token')) { next('/login'); } else { next(); } });这会导致:token已过期,但Cookie还存在,用户卡在白屏。必须解析JWT的exp字段:
function isTokenExpired(token: string): boolean { try { const payload = JSON.parse(atob(token.split('.')[1])); return Date.now() >= payload.exp * 1000; } catch (e) { return true; } } router.beforeEach(async (to, from, next) => { const token = getCookie('access_token'); if (!token) { next('/login'); } else if (isTokenExpired(token)) { try { await tokenManager.refreshToken(); next(); } catch (e) { next('/login'); } } else { next(); } });最后分享一个小技巧:在开发环境,用Chrome插件“EditThisCookie”可手动修改Cookie的
Max-Age,把access_token设为10秒,快速验证续期逻辑是否健壮。生产环境切记关闭此插件,避免误操作。
(全文共计约5820字)