ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JWT与Spring Security:系统间信任建立与鉴权实战

JWT与Spring Security:系统间信任建立与鉴权实战

“trust me”这句话,放在人和人之间是情感表达,放在系统与系统之间,就是一道道需要被验证的技术关卡。前端跟后端说“我是登录用户”,网关跟业务服务说“这个请求已经校验过了”,服务 A 跟服务 B 说“我代表某个用户在操作”——每一句“请相信我”,背后都必须有一个可验证的凭证,否则整条链路就是裸奔。

本文围绕系统间信任关系的建立与验证展开,从认证和授权这两个容易混淆的概念讲起,逐步拆解 JWT 凭证的结构、签名算法、Spring Security 过滤器链的执行顺序,最后落到一套可以跑起来的完整代码:登录签发 Token、请求携带 Token、服务端校验 Token、微服务之间透传信任。适合有 Java/Spring 基础、正在做登录鉴权模块或微服务改造的开发者阅读,也适合刚开始接触 Spring Security 的同学按步骤动手。文中代码都标注了文件路径,可以直接复制到项目里跑通。

1. 从 “trust me” 说起:系统信任关系的本质

1.1 认证与授权:两个最容易被混用的概念

很多项目代码里到处都是auth这个词,但认证(Authentication)和授权(Authorization)解决的是两个完全不同的问题。认证回答的是“你是谁”,它关心身份的真实性,比如用户提交用户名和密码,服务端确认这个身份确实存在且凭证匹配。授权回答的是“你能做什么”,它发生在认证之后,关心权限边界,比如这个用户是普通员工还是管理员,能不能删除订单。

为什么一定要把这两者拆开理解?因为它们的失败处理方式完全不同。认证失败是“我不认识你”,通常返回 401 未授权状态码,前端应该跳转登录页;授权失败是“我认识你,但你不能这么做”,返回 403 禁止访问,前端应该提示无权操作。如果代码里把两者揉在一起,就会出现“权限不足却把用户踢回登录页”这种体验很差的交互,排查问题时也很难区分到底是凭证无效还是权限不够。

在实际项目里,我建议把认证和授权放在两套代码里分别处理:认证交给过滤器(Filter)在请求进入业务逻辑之前完成,授权交给方法注解或路径规则在业务层完成。这样责任清晰,日志也好打。

1.2 信任链:一次请求究竟经过几道关卡

用户点击一次“查看我的订单”,这个请求在到达业务代码之前,其实要走过一条信任链。第一关是入口层,可能由网关做初步的 Token 解析和格式校验;第二关是应用的过滤器链,校验签名、有效期、签发方;第三关是方法级的权限判断,确认当前身份是否有order:read权限;第四关是数据层,确认这条数据确实属于当前用户,防止越权访问(这个叫水平越权,是很多线上事故的源头)。

这条链上任何一关被跳过,整条链路就不再可信。这也是为什么“前端隐藏了按钮”永远不能算作权限控制——请求可以直接用工具构造,绕过所有前端逻辑。

理解信任链的意义在于:你需要在设计阶段就想清楚每一关校验什么、失败后怎么响应、日志记录哪些字段。否则等接口上线之后再来补,往往会出现“同一个 Token 在网关能过、在业务服务过不了”这类让人抓狂的不一致问题。

1.3 为什么用 Token 承载信任,而不是 Session

传统的 Session 方案是服务端保存会话状态,客户端只拿一个无意义的 SessionId。这个方案在单机应用里非常成熟,但到了多实例部署就有了麻烦:会话数据要么共享存储(引入 Redis 依赖),要么做粘性会话(负载均衡变得不灵活)。

Token 方案反过来,把会话状态编码进凭证本身,服务端只负责校验签名,不需要保存任何状态。这带来了两个直接好处:水平扩展变得简单,任意实例都能独立完成校验;跨服务传递变得自然,服务 A 拿到 Token 可以直接交给服务 B。

但代价同样明显:Token 一旦签发,在有效期内就是有效的,服务端无法“主动撤销”单个 Token。所以必须配合较短的有效期和刷新机制,关键操作还要额外校验。这一点在后面讲最佳实践时会重点展开。

2. 环境准备与项目结构

2.1 技术栈与版本说明

本文示例基于 Spring Boot 3.x + Spring Security 6.x + JDK 17 编写。这个组合是目前新项目比较常见的选择,其中 Spring Security 6 相比 5.x 有较大变化,最典型的是WebSecurityConfigurerAdapter被移除,改为声明SecurityFilterChainBean 的方式进行配置,方法链式调用也做了调整。

需要特别说明的是:不同版本之间 API 差异较大,请按你项目的实际版本调整本文示例。如果你还在 Spring Boot 2.7 及以下版本,配置写法需要用WebSecurityConfigurerAdapter继承方式,或者用SecurityFilterChain但换用authorizeRequests()而不是authorizeHttpRequests()。JWT 库方面,本文使用jjwt0.12.x 版本,它在 0.11.x 的基础上重命名了大量方法(setSubject改为subject、parserBuilder改为parser等),如果你的工程里是旧版本,编译时方法找不到,基本都是这个原因。

其他环境要求:JDK 17 及以上(Spring Boot 3 强制要求);构建工具 Maven 3.8+ 或 Gradle 7.5+;IDE 用 IntelliJ IDEA 或 VS Code 都可以;接口调试用 Postman 或 curl 命令。

2.2 依赖引入

新建一个 Spring Boot 工程,在pom.xml中引入下面几项依赖。注意jjwt需要同时引入 api、impl、jackson 三个坐标,只引 api 会在运行时找不到序列化实现。

<!-- 文件路径:pom.xml --> <dependencies> <!-- Web 基础能力,提供内嵌容器与 MVC 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 安全框架,提供过滤器链、密码加密、权限注解 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JWT 核心 API,用于构建和解析 Token --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <!-- 参数校验,登录接口用得上 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

版本号如果和你项目其他依赖有冲突,可以用dependencyManagement统一管理,避免出现NoClassDefFoundError这类穿透到运行期才暴露的问题。

2.3 项目目录结构

为了避免后面代码贴出来找不到位置,先把整体结构列清楚。包名你可以按自己公司规范调整,本文示例统一用com.example.trust。

src/main/java/com/example/trust ├── TrustApplication.java // 启动类 ├── config │ ├── SecurityConfig.java // 过滤器链配置 │ └── JwtProperties.java // 密钥与有效期配置绑定 ├── controller │ ├── AuthController.java // 登录接口 │ └── OrderController.java // 受保护的示例业务接口 ├── filter │ └── JwtAuthenticationFilter.java // Token 校验过滤器 ├── service │ ├── JwtService.java // Token 签发与解析 │ └── UserService.java // 用户查询(示例内存实现) └── model ├── LoginRequest.java // 登录入参 └── LoginResponse.java // 登录返回 src/main/resources └── application.yml // 密钥、过期时间等配置

这个结构刻意把“签发/解析能力”抽成JwtService,“校验时机”放进过滤器,“权限规则”放进配置类,三个关注点分离。后面如果要做密钥轮换或者多签发方支持,只需要改动JwtService,其他代码不受影响。

3. JWT 与信任凭证的核心原理

3.1 三段式结构:它到底存了什么

JWT(JSON Web Token)是一个用点号分隔的三段字符串,形如xxxxx.yyyyy.zzzzz。三段分别是 Header、Payload 和 Signature,每一段都是 Base64URL 编码。

Header 描述签名算法和类型,通常是{"alg":"HS256","typ":"JWT"}。Payload 存放声明(Claims),分为标准声明和自定义声明:标准的有sub(主体,通常是用户标识)、iat(签发时间)、exp(过期时间)、iss(签发方),自定义的可以放用户角色、租户 ID 等业务信息。Signature 是对前两段内容的签名,用于防篡改。

这里有一个新手最常见的误区:JWT 的 Payload 只是 Base64 编码,不是加密。任何人拿到 Token,随便找个在线工具就能解出里面的内容。所以绝对不要往 Payload 里塞手机号、身份证号、密码这类敏感信息。Token 能保证的是“没被改过”,而不是“别人看不到”。

另一个常见误区是把角色、权限全部塞进 Token,导致 Token 体积巨大,每次请求都要带上几百字节甚至上 KB 的头部。更麻烦的是权限变更后,旧 Token 里的权限还是旧的,必须等它过期才生效。我的建议是 Token 里只放最少量的标识(用户 ID、租户 ID),权限实时从缓存或数据库查。

3.2 签名算法与密钥长度

本文使用的是 HMAC-SHA256(HS256),对称加密,签发和校验用同一个密钥。它的优点是性能好、实现简单,缺点是所有校验方都持有密钥,等于所有校验方都能签发。如果你的系统里调用方很多、安全边界复杂,应该考虑 RS256 这种非对称算法:私钥签发、公钥校验,校验方拿不到签发能力。

密钥长度是一个必须重视的点。Keys.hmacShaKeyFor()对密钥有最小长度要求,HS256 要求至少 256 位(32 字节)。如果你图省事写一句secret,运行时就会抛WeakKeyException。这不是框架在刁难你,而是短密钥确实容易被暴力破解。

密钥的管理方式更需要强调:绝对不能硬编码在代码里提交到仓库。正确做法是通过环境变量、配置中心或密钥管理服务注入,并且区分环境。生产密钥一旦泄露,攻击者可以伪造任意用户的 Token,这是最高级别的安全事故。所以下面示例里,我会通过application.yml占位符 + 环境变量注入的方式来写,同时提醒你在生产环境做替换。

3.3 Spring Security 过滤器链的执行顺序

Spring Security 的核心是一条过滤器链,请求按顺序穿过这些过滤器,每个过滤器负责一类事情。跟本文相关的关键是:认证过滤器要在UsernamePasswordAuthenticationFilter之前执行,这样自定义的 Token 校验才能在默认表单登录逻辑之前接管请求。

请求进来后的处理顺序大致是这样的:

  1. SecurityContextHolderFilter负责清理和加载安全上下文。
  2. 我们的JwtAuthenticationFilter解析请求头,校验 Token,把身份信息写入SecurityContext。
  3. AuthorizationFilter根据配置的路径规则判断当前身份是否有权访问。
  4. 进入 DispatcherServlet,执行 Controller 逻辑。

这个顺序解释了为什么很多同学遇到“明明 Token 是对的,接口还是返回 401”——如果你的过滤器加在了错误的位置,比如加在AuthorizationFilter之后,那么权限判断的时候SecurityContext还是空的,自然判定为未认证。

还有一个隐含细节:SecurityContext默认是基于 ThreadLocal 的,一次请求一个线程,所以过滤器里写入的身份信息在 Controller 里能读到。但如果你在业务代码里开了新线程做异步处理,是读不到这个上下文的,需要手动传递。

4. 完整实战:从登录到校验的闭环

4.1 配置项与属性绑定

先在application.yml中定义 JWT 相关配置。注意${JWT_SECRET:...}这种写法表示优先读环境变量,读不到再用默认值,方便本地开发。

# 文件路径:src/main/resources/application.yml server: port: 8080 jwt: # 生产环境请通过环境变量 JWT_SECRET 注入,不要提交到仓库 secret: ${JWT_SECRET:local-dev-only-secret-key-please-change-32bytes} # 访问令牌有效期,单位秒,示例为 30 分钟 expire-seconds: 1800 # 签发方标识,便于多系统区分 issuer: trust-demo

对应的属性绑定类如下。使用@ConfigurationProperties比散落的@Value更好维护,字段多了以后尤其明显。

// 文件路径:src/main/java/com/example/trust/config/JwtProperties.java package com.example.trust.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties { /** 签名密钥,长度至少 32 字节 */ private String secret; /** 访问令牌有效期(秒) */ private long expireSeconds; /** 签发方标识 */ private String issuer; public String getSecret() { return secret; } public void setSecret(String secret) { this.secret = secret; } public long getExpireSeconds() { return expireSeconds; } public void setExpireSeconds(long expireSeconds) { this.expireSeconds = expireSeconds; } public String getIssuer() { return issuer; } public void setIssuer(String issuer) { this.issuer = issuer; } }

4.2 Token 签发与解析服务

这是整套逻辑的核心。签发时把用户 ID 放进subject,角色放进自定义声明;解析时先验签再取声明,任何异常都统一处理成“Token 无效”。

// 文件路径:src/main/java/com/example/trust/service/JwtService.java package com.example.trust.service; import com.example.trust.config.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.List; @Service public class JwtService { private final JwtProperties properties; private final SecretKey key; public JwtService(JwtProperties properties) { this.properties = properties; // 密钥长度不足 32 字节时会抛出 WeakKeyException,这里提前暴露问题 this.key = Keys.hmacShaKeyFor( properties.getSecret().getBytes(StandardCharsets.UTF_8)); } /** * 签发访问令牌 * * @param userId 用户唯一标识 * @param roles 角色列表 * @return 签名后的 JWT 字符串 */ public String issueToken(String userId, List<String> roles) { Instant now = Instant.now(); Instant expireAt = now.plusSeconds(properties.getExpireSeconds()); return Jwts.builder() .subject(userId) .issuer(properties.getIssuer()) .issuedAt(Date.from(now)) .expiration(Date.from(expireAt)) .claim("roles", roles) .signWith(key) .compact(); } /** * 解析并校验令牌,签名错误、过期、格式不对都会抛出 JwtException */ public Claims parse(String token) throws JwtException { return Jwts.parser() .verifyWith(key) .requireIssuer(properties.getIssuer()) .build() .parseSignedClaims(token) .getPayload(); } }

几处值得说明的地方。signWith(key)会依据密钥长度自动选择算法,32 字节对应 HS256,64 字节对应 HS512,不用手动指定。requireIssuer()是校验签发方的关键,多系统共存时能挡住“别的系统签发的 Token 拿来用”这种越权场景。parseSignedClaims()一次性完成验签、过期检查、签发方检查,比手动逐项判断更不容易遗漏。

如果你项目里是jjwt0.11.x,等价写法是Jwts.parserBuilder().setSigningKey(key).requireIssuer(...).build().parseClaimsJws(token).getBody(),方法名不一样,逻辑完全一致。

4.3 密码加密与用户查询

密码绝对不能用明文或 MD5 存储。Spring Security 提供的BCryptPasswordEncoder是推荐选择,它自带随机盐,同一个密码每次加密结果都不一样,并且计算成本可调,能显著提高离线爆破的代价。

// 文件路径:src/main/java/com/example/trust/service/UserService.java package com.example.trust.service; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; /** * 示例用的内存用户存储,真实项目请替换为数据库查询 */ @Service public class UserService { private final PasswordEncoder passwordEncoder; /** 用户名 -> {加密后的密码, 角色列表} */ private final Map<String, UserRecord> users; public UserService(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; // 启动时把示例密码加密,避免代码里出现明文 this.users = Map.of( "admin", new UserRecord( passwordEncoder.encode("admin123"), List.of("ADMIN")), "alice", new UserRecord( passwordEncoder.encode("alice123"), List.of("USER")) ); } public Optional<UserRecord> findByUsername(String username) { return Optional.ofNullable(users.get(username)); } public boolean matches(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); } public record UserRecord(String encodedPassword, List<String> roles) { } }

这里用record承载用户数据,代码更紧凑。真实项目里findByUsername应该走数据库或缓存,并且要注意:无论用户是否存在,都不要在响应里暴露差异,否则攻击者可以枚举出哪些用户名是有效的。登录失败统一返回“用户名或密码错误”。

4.4 认证过滤器:把 Token 变成安全上下文

这个过滤器是整个机制的中枢。它做三件事:从请求头取 Token、校验并解析、把身份写入上下文。注意它不能抛异常中断流程,而是要在校验失败时保持上下文为空,交给后续的授权环节去拒绝。

// 文件路径:src/main/java/com/example/trust/filter/JwtAuthenticationFilter.java package com.example.trust.filter; import com.example.trust.service.JwtService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String HEADER = "Authorization"; private static final String PREFIX = "Bearer "; private final JwtService jwtService; public JwtAuthenticationFilter(JwtService jwtService) { this.jwtService = jwtService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader(HEADER); // 没有携带令牌时直接放行,由后续授权规则决定是否拒绝 if (header == null || !header.startsWith(PREFIX)) { chain.doFilter(request, response); return; } String token = header.substring(PREFIX.length()).trim(); try { Claims claims = jwtService.parse(token); @SuppressWarnings("unchecked") List<String> roles = claims.get("roles", List.class); List<SimpleGrantedAuthority> authorities = roles == null ? List.of() : roles.stream() .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) .toList(); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug("令牌校验通过, userId={}", claims.getSubject()); } catch (JwtException | IllegalArgumentException e) { // 令牌无效时不写入上下文,交由授权环节返回 401 SecurityContextHolder.clearContext(); log.warn("令牌校验失败, reason={}", e.getMessage()); } chain.doFilter(request, response); } }

继承OncePerRequestFilter而不是直接实现Filter,是为了保证同一个请求内只执行一次,避免异步转发(forward)时重复解析。日志里只记录失败原因,不要把完整 Token 打出来,因为日志系统往往权限更宽,Token 泄露等同于身份泄露。

4.5 安全配置:路径规则与无状态会话

// 文件路径:src/main/java/com/example/trust/config/SecurityConfig.java package com.example.trust.config; import com.example.trust.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分离场景下无状态接口不使用 Cookie 会话,可关闭 CSRF .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/health").permitAll() .requestMatchers(HttpMethod.GET, "/orders/**").hasRole("USER") .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

关于csrf().disable()需要说明清楚:只有在你完全不依赖 Cookie 传递会话凭证的前提下才可以关闭。如果系统同时用 Cookie 做会话且关闭了 CSRF 防护,就会暴露跨站请求伪造风险。前后端分离 + Bearer Token 模式之所以能关,是因为浏览器不会自动携带 Token,攻击者构造的跨站请求带不上凭证。如果你的系统是传统服务端渲染加表单提交,务必保持 CSRF 开启。

4.6 登录接口与受保护接口

// 文件路径:src/main/java/com/example/trust/controller/AuthController.java package com.example.trust.controller; import com.example.trust.service.JwtService; import com.example.trust.service.UserService; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/auth") public class AuthController { private final UserService userService; private final JwtService jwtService; public AuthController(UserService userService, JwtService jwtService) { this.userService = userService; this.jwtService = jwtService; } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { return userService.findByUsername(request.username()) .filter(u -> userService.matches( request.password(), u.encodedPassword())) .map(u -> { String token = jwtService.issueToken( request.username(), u.roles()); return ResponseEntity.ok(Map.of( "token", token, "tokenType", "Bearer")); }) // 用户不存在与密码错误返回同一提示,避免用户名枚举 .orElseGet(() -> ResponseEntity.status(401) .body(Map.of("message", "用户名或密码错误"))); } public record LoginRequest(@NotBlank String username, @NotBlank String password) { } }
// 文件路径:src/main/java/com/example/trust/controller/OrderController.java package com.example.trust.controller; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; @RestController @RequestMapping("/orders") public class OrderController { @GetMapping public Map<String, Object> list(Authentication authentication) { // 从安全上下文中取当前用户,不要从请求参数里取 userId String userId = String.valueOf(authentication.getPrincipal()); return Map.of( "userId", userId, "orders", List.of( Map.of("orderId", "A1001", "amount", 199), Map.of("orderId", "A1002", "amount", 88) )); } }

注意OrderController里取当前用户的方式:从Authentication里拿,而不是从请求参数里拿。如果接口设计成GET /orders?userId=xxx,那就是典型的水平越权漏洞——只要改一下参数就能看到别人的订单。真实业务里,查询条件必须强制附加当前用户 ID 作为过滤条件。

4.7 运行与验证

启动应用后,用 curl 依次验证三个场景。先拿一个合法 Token:

# 1. 登录获取令牌 curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"alice123"}'

预期返回类似下面的结构,token字段就是三段式的 JWT:

{ "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs...", "tokenType": "Bearer" }

然后带 Token 访问受保护接口:

# 2. 携带令牌访问订单接口 curl http://localhost:8080/orders \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs..."

预期返回当前用户和订单列表。接着验证失败场景:

# 3. 不带令牌,预期 401 curl -i http://localhost:8080/orders # 4. 篡改令牌最后一段,预期 401(验签失败) curl -i http://localhost:8080/orders -H "Authorization: Bearer xxx.yyy.zzz" # 5. 用 alice 的令牌访问管理员接口,预期 403(认证通过但授权不足) curl -i http://localhost:8080/admin/users \ -H "Authorization: Bearer <alice的令牌>"

这三个失败的用例很关键,很多同学只测通了一条成功路径就认为做完了。401 和 403 的区分、签名篡改能否被拦住,才是判断鉴权是否真的生效的标准。

5. 服务间信任:微服务调用如何传递身份

5.1 Token 透传:最简单也最容易踩坑的方案

服务 A 收到用户请求后要调用服务 B,最直接的做法是把原始 Token 放进请求头继续往下传。服务 B 用同一个密钥或公钥校验,就能拿到同一个身份。这种方式实现成本低,链路清晰。

但它有两个必须注意的点。第一,Token 的有效期在链路中持续消耗,如果 A 调用 B 时 Token 只剩 10 秒,B 处理稍慢就会 401。建议在调用前检查剩余有效期,不足时提前刷新。第二,Token 会流经多个服务,任何一个服务的日志、链路追踪系统如果全量记录请求头,Token 就会散落在各处。所以要在日志配置里对Authorization头做脱敏。

5.2 内部服务签名:不依赖用户身份的调用

有些调用并不代表某个用户,比如定时任务触发数据同步。这时不应该伪造用户 Token,而是用服务身份。常见做法是给每个服务分配一对clientId/clientSecret,调用时用密钥签名请求(签名内容包含时间戳、请求体和随机数),被调方校验签名并检查时间戳窗口(比如 ±5 分钟)防止重放。

这块实现相对复杂,工程上更常见的替代方案是引入统一的服务网格或内部网关统一处理,业务代码不用关心。无论用哪种方式,原则是一致的:内部网络不等于可信网络,涉及到数据变更的接口,仍然需要校验调用方身份。

5.3 密钥轮换:让信任关系可以平滑更新

密钥一旦固定不变,泄露风险就随使用时长线性上升。轮换的难点在于:签发方换新密钥的瞬间,旧 Token 还没过期,校验方如果只认新密钥,所有在线用户会集体掉线。

解决办法是让校验方同时信任多个密钥,通过 Header 里的kid(密钥标识)字段选择对应的密钥。轮换流程是:先让所有校验方上线“双密钥支持”,再切换签发方使用新密钥,等旧 Token 全部过期(超过一个完整有效期)后移除旧密钥。整个过程用户无感知。如果你的系统规模还不大,至少要做到密钥可以通过配置中心热更新,而不是改代码重新发版。

6. 常见问题与排查清单

鉴权模块的问题表现形式很集中,但原因往往不止一种。下面这张表按现象归类,遇到报错时可以逐条比对。

问题现象常见原因解决思路
启动报WeakKeyException密钥长度不足 32 字节换用足够长度的密钥,或改用Keys.secretKeyFor(SignatureAlgorithm.HS256)生成
所有请求都 401过滤器未注册或顺序错误检查addFilterBefore是否加在UsernamePasswordAuthenticationFilter之前
所有请求都 401Authorization头缺少Bearer前缀前端拼接时统一封装,服务端严格按前缀截取
明明登录成功但访问接口 403hasRole("USER")与授权值不匹配过滤器里加的权限前缀要和hasRole约定一致,hasRole("USER")实际校验ROLE_USER
上线后部分用户频繁掉线多实例密钥不一致密钥统一从配置中心下发,不写死在各自配置文件
时间一过零点就大面积 401服务器时区或时间不同步统一使用 UTC 时间,部署 NTP 时间同步
旧 Token 在权限变更后仍有旧权限权限被写进了 Token Payload改为 Token 只放用户 ID,权限实时查询缓存
报InvalidClaimException: iss多系统共用密钥但签发方不同签发时设置 issuer,校验时用requireIssuer限制
日志出现大量验签失败可能是扫描器探测或密钥被误换结合来源 IP 和错误分布判断,必要时做频率限制

排查时建议按“请求头是否正确 → 过滤器是否执行 → 上下文是否写入 → 授权规则是否匹配”这个顺序走。一个很实用的小技巧是在过滤器里打一行 debug 日志,把SecurityContext里的权限列表输出出来,能快速定位是认证没通过还是授权没配对。

7. 最佳实践与工程建议

有效期设计要有层次。访问令牌有效期短(比如 30 分钟),刷新令牌有效期长(比如 7 天),刷新令牌一次性使用并且可撤销。这样既控制了泄露后的风险窗口,又不会让用户频繁重新登录。刷新令牌必须存储服务端状态,否则就失去了“可撤销”这个核心价值。

密钥管理纳入发布流程。密钥不要放在代码仓库、不要放在前端、不要出现在日志。生产环境通过环境变量或密钥管理服务注入,不同环境使用不同密钥,并且把密钥轮换写进运维手册。这条做不好,前面所有代码都是白写。

错误响应要统一但不要泄露细节。对外统一返回“用户名或密码错误”“登录状态已失效”,不要把“用户不存在”“签名不匹配”“Token 已过期”这些内部原因直接透给前端。详细原因写日志,接口只给用户能理解的信息。

日志脱敏要做成默认行为。请求头、请求体、响应体里都可能带凭证。建议在日志框架层面统一配置敏感字段过滤,而不是靠每个开发者记得手动脱敏——靠人记的事情,迟早会漏。

权限校验要有兜底策略。路径规则容易写漏,建议默认拒绝(anyRequest().authenticated()作为兜底),而不是默认放行。新增接口时如果忘记配置规则,默认是受保护的,这比默认开放安全得多。

避免过度依赖前端控制。按钮隐藏、菜单不渲染都只是体验优化,不是安全措施。所有涉及数据读取和状态变更的接口,都必须在服务端做身份和归属校验,尤其是“根据 ID 查询详情”这类接口,必须校验数据是否属于当前用户。

给测试留出可验证的失败路径。写单元测试时,除了验证成功场景,至少要覆盖:过期 Token、篡改签名、缺少 Token、权限不足这四种。这几条用例能在重构过滤器链时第一时间发现回归问题。

生产变更要稳妥。修改鉴权逻辑属于高风险变更,建议先在测试环境完整验证,采用灰度发布,保留快速回滚方案。涉及用户数据的权限规则调整,最好提前通知相关方,避免上线后出现大面积无法访问的情况。

8. 继续深入的方向

把上面的代码跑通之后,你已经具备了一套可用的无状态认证基础。接下来值得继续研究的方向有几个:一是权限模型升级,从简单的角色(RBAC)走向基于资源和操作的细粒度权限(如order:read、order:delete),配合方法级注解@PreAuthorize使用;二是单点登录与多端统一,研究 OAuth 2.1 和 OIDC 的授权码流程,理解第三方登录场景下信任是如何建立的;三是令牌的主动失效,通过维护一个短期的失效列表或版本号,解决“用户改密码后旧 Token 仍然有效”的问题。

如果你想现在就动手加固手上的项目,有三个动作性价比最高:把硬编码的密钥挪到环境变量;检查所有“按 ID 查详情”的接口是否做了归属校验;给鉴权模块补上过期、篡改、越权三类测试用例。这三件事花不了多少时间,但能挡掉大部分真实存在的风险。

信任这件事,在系统里从来不是靠一句“请相信我”成立的,它靠的是每一个环节都把该做的校验老老实实做完。代码写到位了,别人才敢信。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进