尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Security 5.7+ JWT生产级权限链路实战
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.7LTS版本、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.ymlspring: 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/PostgreSQLComponent 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 RowMapperRegisteredClient { 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且密钥轮换要无感HS256HMAC-SHA256用同一个密钥签名和验签简单高效。但它有个致命缺陷授权服务器和资源服务器必须共享同一份密钥。在微服务架构下这意味着要么把密钥硬编码进每个服务的配置要么用配置中心统一推送——但密钥轮换时所有服务必须同时重启否则新旧密钥并存期间会出现大量InvalidSignatureException。RS256RSA-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在授权服务器中注入JwtEncoderBean 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 OAuth2TokenCustomizerJwtEncodingContext { private final RedisTemplateString, String redisTemplate; public CustomTokenCustomizer(RedisTemplateString, 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 RedisTemplateString, String redisTemplate; public ValidatingRefreshTokenAuthenticationProvider( OAuth2RefreshTokenService refreshTokenService, RedisTemplateString, 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 ClaimsPreAuthorize(hasRole(ADMIN))看似简洁但它依赖Spring Security的Authentication对象中getAuthorities()返回的角色列表。而JWT默认只把scope映射为GrantedAuthority角色role需手动注入。更糟的是若前端在JWT中写roles: [USER,PREMIUM]但Spring Security默认不解析此字段hasRole(PREMIUM)永远为false。正确做法自定义JwtAuthenticationConverter从JWT Claims中提取roles数组并转换为SimpleGrantedAuthorityBean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtAuthenticationConverter converter new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt - { // 从JWT中提取roles数组 ListString 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 OAuth2TokenCustomizerJwtEncodingContext 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 ResponseEntityErrorResponse 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 ResponseEntityErrorResponse 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 方法级鉴权性能优化避免每次请求都查DBPreAuthorize(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));然后自定义PermissionEvaluatorComponent 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(); ListString 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-ID12345用户唯一ID下游服务可直接用X-User-RolesUSER,PREMIUM角色列表逗号分隔X-User-Permissionsarticle: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 MonoVoid 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); ListString roles jwt.getClaimAsStringList(roles); ListString 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_tokenRestController 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 MonoMapString, Object refreshAccessToken( RequestBody MapString, String requestBody) { String refreshToken requestBody.get(refresh_token); return webClient.post() .uri(/oauth2/token) .header(Content-Type, application/x-www-form-urlencoded) .bodyValue(grant_typerefresh_tokenrefresh_token refreshToken) .retrieve() .bodyToMono(new ParameterizedTypeReferenceMapString, 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 MonoVoid 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: methodGET path/api/articles/123 user_id45678 ip203.0.113.42 user_agentMozilla/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读取tokenComponent public class CookieJwtAuthenticationConverter implements ConverterServerWebExchange, MonoAbstractAuthenticationToken { private final JwtDecoder jwtDecoder; public CookieJwtAuthenticationConverter(JwtDecoder jwtDecoder) { this.jwtDecoder jwtDecoder; } Override public MonoAbstractAuthenticationToken convert(ServerWebExchange exchange) { String accessToken getCookieValue(exchange, access_token); if (accessToken null) { return Mono.empty(); } try { Jwt jwt jwtDecoder.decode(accessToken); CollectionGrantedAuthority 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: Promisevoid | 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字
RELATED

相关推荐

Codex与Claude Code实测对比:AI编程助手选型指南

Codex与Claude Code实测对比:AI编程助手选型指南

从去年年底开始,AI编程助手这个赛道就进入了“神仙打架”的模式。一边是OpenAI押注的Codex,一边是Anthropic力推的Claude Code,两边都能直接读写你的代码仓库、自动跑测试、帮你完成跨文件的修改任务。我过去这几个月把两个工具都重度用在了真…

📅 2026/10/12 12:53:49
Sicar-Training-show-V16训练演示系统:部署配置与避坑指南

Sicar-Training-show-V16训练演示系统:部署配置与避坑指南

简介:该压缩包为Sicar-Training-show-V16程序,属于西门子Sicar标准程序配套的培训演示资源,面向汽车行业自动化产线调试、维护的PLC工程师,也适合正在学习TIA Portal V16集成项目的技术人员。包内共106个文件,以XML、I…

📅 2026/10/12 12:53:49
Agent外挂记忆系统实战:从原理到接入Mem0的完整指南

Agent外挂记忆系统实战:从原理到接入Mem0的完整指南

如果你做过一个稍微上点规模的AI客服Agent,大概率遇到过这种崩溃瞬间:用户问“我上次那个退款怎么回事”,Agent一脸茫然地开始重复要订单号;或者用户已经说清楚了“我在上海,想找一个周末的线下活动”,下一…

📅 2026/10/12 12:48:49
MORE NEWS

更多资讯

📰

AI漫剧2026生存指南:合规红线、模型选型与出海工业化

AI漫剧还能不能干?这是我这半年被问到最多的一句话。2024年大家还在比谁生成得快,2025年比谁做得像,而到了2026年下半年,真正的问题变成了:谁能在合规框架里稳定赚钱。先说结论:能,但玩法全变了…

📰

用 claude-mem 为 Claude 对话搭建长期记忆库的实战指南

给自己常用的对话模型配一个长期记忆库,这件事我惦记了挺久。每次聊完一个技术方案,隔两周再回头问细节,发现它只记得零散片段,完整的来龙去脉早就丢了。直到我试了 claude-mem 这套工具,思路一下子通了:把…

📰

操作系统进程同步实战:小和尚打水模型与信号量PV操作详解

1. 从"小和尚打水、老和尚喝水"说起:这个模型到底在解决什么第一次看到"小和尚打水、老和尚喝水"这个题目,很多人会以为是个脑筋急转弯,其实它是操作系统课程里最经典的进程同步模型之一,本质上是生产者-消费…

📰

基于Scala的交通拥堵预测系统:数据库设计与滑动窗口算法实战

简介:这是一份基于Scala的交通拥堵预测源码,来自个人大三数据库课程设计,经过导师指导与严格评审获得高分。面向计算机相关专业的教师、正在做课程设计或大作业的学生,也可作为项目实战演练和毕业设计参考。资源围绕交通拥堵预测场…

📰

国内镜像版Dify部署指南:从零搭建到避坑排查

简介:这份资源是面向国内开发者与运维人员的 dify-main Docker 镜像包,主要解决在国内网络环境下拉取官方镜像速度慢、不稳定甚至无法访问的问题,适合需要快速搭建与测试 dify 相关技术栈的中初级开发者使用。压缩包为 zip 格式,共…

📰

4×4矩阵薄膜按键驱动指南:从接线到Arduino扫描代码

我第一次拿到44矩阵薄膜按键,第一反应是这玩意儿不像电子元件,更像一张贴纸。软、薄、能弯,按压行程短到几乎察觉不到,可它上面确确实实装了16个独立按键。接上Arduino之后,我只用了8根杜邦线,就能在串口监…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬