SpringBoot + JWT 集成实战:从登录到鉴权的完整落地
1. 项目背景与整体方案拆解
1.1 认证和鉴权:先把概念掰扯清楚
很多刚接触后端的同学容易把认证(Authentication)和鉴权(Authorization)混在一起说,但这两个东西在项目里必须分得清清楚楚,否则后面代码全是坑。
认证解决的是“你是谁”的问题。用户拿着用户名密码来登录,服务端校验通过后确认这个人是合法用户,这就是认证。鉴权解决的是“你能干什么”的问题。同一个系统里,普通用户只能看自己的订单,管理员可以看所有人的订单,运营人员可以改商品价格,这种按身份分配操作权限的过程就是鉴权。
拿生活类比,认证就是机场安检,出示身份证确认你是本人;鉴权就是登机牌上的座位等级,经济舱不能进头等舱休息室。安检不过,连候机厅都进不去;过了安检但没有对应舱位权限,一样会被拦下来。在Web系统里,登录接口负责认证,之后每一次请求的权限校验负责鉴权。两者配合,才算完整的访问控制链路。
这也是为什么SpringBoot项目里JWT这么流行——它把认证后的身份凭证做成一个可以随身携带的令牌,后端拿这个令牌既能识别身份(认证结果),又能从中读取角色权限来做鉴权判断。
1.2 为什么选JWT而不是传统Session
传统的Session方案在单体应用里很好用:用户登录后,服务端生成一个Session ID存到内存,同时把这个ID通过Cookie写到浏览器。后续请求带上Cookie,服务端从内存里查一下Session是否存在,存在就说明已登录。
但到了分布式环境和前后端分离场景,Session方案有几个让人头疼的问题。第一,Session存储在服务端内存,多台服务器部署时,请求被负载均衡到另一台机器,那台机器上没有这个Session,用户就变成了未登录状态。解决办法是引入Redis做Session共享,但这就增加了架构复杂度。第二,前后端分离后,前端可能部署在独立域名,跨域请求携带Cookie需要额外配置CORS,还容易遇到Cookie同源策略的限制。第三,Session ID本身没有任何业务含义,服务端必须留着状态才能识别它,这与RESTful API提倡的无状态理念相悖。
JWT(JSON Web Token)方案把状态从服务端搬到了客户端。用户登录成功后,服务端签发一个JSON格式的令牌,里面包含用户ID、用户名、过期时间、角色等字段,通过签名保证内容不被篡改。客户端在请求头里带上这个令牌,服务端验签通过后就能信任令牌内容。因为没有服务端存储,天然支持水平扩展,任何一台服务器只要持有相同的密钥,都能校验令牌合法性。这也是JWT在微服务架构里成为主流的原因。
JWT还有一个直接的好处:把用户信息直接编码在令牌里。以前用Session,服务端每次请求都要查一次Session存储;用JWT,验签之后直接从令牌中取用户ID和角色,一次解密操作就把认证和用户信息获取都完成了。在接口调用频繁的业务系统里,这个效率差距非常可观。
1.3 JWT的结构与签名原理简析
一个标准的JWT长得像这样:
code复制eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcklkIjoxMDAxLCJyb2xlIjoiQURNSU4ifQ.4xXcjF3zQ0BkFqeXgqQN6GZQeO0k0V9uB2eXw0JjXo
中间有两个点,把它拆成三段:Header(头部)、Payload(载荷)、Signature(签名)。
Header部分一般只有两个字段:alg表示签名算法(常用HS256),typ表示令牌类型(固定JWT)。Payload部分放业务数据,比如用户ID、用户名、角色、签发时间、过期时间。Signature部分是把Header和Payload拼接后,用密钥进行签名计算得到的结果。
签名的作用是防止篡改。任何人拿到令牌后都能直接解码出Header和Payload的内容,但如果没有密钥,就无法伪造合法的签名。接收方验签时,用同样的算法和密钥对Header和Payload重新计算签名,对比是否一致。只要密钥不泄露,令牌内容就是可信的。需要特别注意的是,Payload里的信息是Base64编码,不是加密。写JWT工具类时,不能把密码、身份证号、手机号这类敏感信息直接放进Payload。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与JWT工具类实现
2.1 依赖引入与springboot版本适配
实际项目中我用的是Java 8 + SpringBoot 2.7.x + jjwt 0.11.5这一套组合,稳定性好,网上遇到的问题也基本都有解决方案。
pom.xml里加下面这些依赖:
xml复制<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>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
提示:SpringBoot版本选型上有一个容易被忽视的坑。SpringBoot 3.x要求Java 17起步,同时把javax包迁移到了jakarta包。很多老项目还在用Java 8,直接升级SpringBoot 3.x会报javax.servlet不存在之类的编译错误。如果项目还在用JDK 8,老老实实选SpringBoot 2.7.x,别追求版本高。热词里有人搜“springboot版本太高”,多半就是踩了这个包名迁移的坑。
我自己在项目里查过一个诡异问题:service层有没有SpringSecurity过滤器的关键写法在SpringBoot 2.7和3.x之间完全不同,很多人照着网上新教程配,JDK版本不匹配就各种报错。这点必须在一开始就定清楚。
2.2 配置文件里的密钥与过期时间
在application.yml里定义JWT相关参数:
yaml复制jwt:
# 签名的密钥,生产环境务必放到配置中心或环境变量,不要写死在代码里
secret: your-256-bit-secret-key-please-change-in-production-1234567890
# 过期时间,单位毫秒,默认7天
expire: 604800000
# 请求头名称
header: Authorization
# token前缀
prefix: "Bearer "
密钥长度这里有个细节。jjwt 0.11.x版本要求HS256算法的密钥长度不少于256位,也就是32字节。如果密钥太短,启动时会抛WeakKeyException异常。上面示例里的密钥字符串要保证超过32个字符,用的时候最好生成一个足够长的随机字符串。
2.3 核心工具类JwtUtil
JwtUtil是整个认证体系的地基,封装了生成Token、解析Token、校验Token三个核心方法。代码看起来不长,但每个细节都值得解释一下。
java复制@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Long expire;
@Value("${jwt.header}")
private String header;
@Value("${jwt.prefix}")
private String prefix;
/**
* 从配置的密钥字符串生成签名密钥对象
*/
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成Token
* @param userId 用户ID
* @param username 用户名
* @param role 角色
*/
public String generateToken(Integer userId, String username, String role) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析Token,返回Claims(载荷部分)
*/
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 判断Token是否有效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 从请求中提取Token
*/
public String getTokenFromRequest(HttpServletRequest request) {
String authHeader = request.getHeader(header);
if (authHeader != null && authHeader.startsWith(prefix)) {
return authHeader.substring(prefix.length());
}
return null;
}
/**
* 获取用户ID
*/
public Integer getUserId(String token) {
return parseToken(token).get("userId", Integer.class);
}
/**
* 获取角色
*/
public String getRole(String token) {
return parseToken(token).get("role", String.class);
}
}
写这个工具类时有几个关键决策需要说明。
第一是setId(now)和setExpiration(expireDate)的配合。签发时间的作用是记录Token生成时间点,过期时间则是Token的生命周期终点。解析Token时,parseClaimsJws会自动校验过期时间,一旦过期会抛ExpiredJwtException,校验逻辑里把它当作无效Token处理。
第二是claim("role", role)这种自定义字段的用法。JWT规范允许在Payload中任意添加业务字段,但要注意Java的Map类型兼容性问题。数字类型在解析后可能自动变成Integer或Long,取的时候要小心转型异常。我在项目里吃过这个亏,存入的userId是Integer,如果某些地方生成时用了Long类型,解析时拿Integer接收就会报ClassCastException。
第三是签名字段的统一。signWith(getSecretKey(), SignatureAlgorithm.HS256)这里指定了签名算法和密钥。jjwt 0.11.x推荐使用Keys.hmacShaKeyFor来从字节数组构造密钥,不要用旧版的setSigningKey(String)直接传字符串,后者在一些版本会被当作Base64编码,导致密钥长度校验失败。
工具类里还写了一个getTokenFromRequest方法,专门用来从请求头中提取去掉Bearer 前缀的纯Token。这个前缀是RFC 6750规定的标准写法,表示这是一个Bearer Token。前端在请求头里传的时候要带上Bearer 前缀,后端解析时去掉前缀才是真正要验签的令牌。前后端约定不一致会导致一直提示token无效,这是联调阶段最高频的问题之一。
3. 认证流程落地:登录接口与过滤器
3.1 登录接口怎么写
登录接口的核心逻辑:接收用户名密码,校验用户名是否存在,再校验密码是否正确,都通过了就签发Token返回给前端。伪代码实现如下:
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Resource
private UserService userService;
@Resource
private JwtUtil jwtUtil;
@Autowired
private AuthenticationManager authenticationManager;
@PostMapping("/login")
public Result login(@RequestBody LoginRequest loginRequest) {
// 参数校验
if (StringUtils.isBlank(loginRequest.getUsername())
|| StringUtils.isBlank(loginRequest.getPassword())) {
return Result.error("用户名和密码不能为空");
}
// Spring Security的认证管理器负责校验用户名密码
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(), loginRequest.getPassword()));
// 认证成功后从SecurityContext获取用户信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
User user = userService.findByUsername(userDetails.getUsername());
// 生成Token
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("username", user.getUsername());
data.put("role", user.getRole());
data.put("expiresIn", jwtUtil.getExpire() / 1000);
return Result.success(data);
}
}
密码校验这一块强烈建议别自己手写密码比对逻辑。Spring Security自带的AuthenticationManager已经封装了完整的认证流程,包括密码加密验证、用户状态检查等,用起来比自己造轮子靠谱得多。
如果项目里没有引入Spring Security,只有一个简单的登录接口,那至少也要用BCrypt对密码做哈希对比。千万别把明文密码存数据库,这属于低级错误。
3.2 用户密码加密
用户注册时密码要加密处理。BCrypt算法是目前比较推荐的选择,加了盐且自适应强度,同一个密码每次加密得到的哈希值都不同,有效防止彩虹表攻击。
java复制@Service
public class UserServiceImpl implements UserService {
@Resource
private UserMapper userMapper;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
public void register(RegisterRequest request) {
// 检查用户名是否已存在
if (userMapper.findByUsername(request.getUsername()) != null) {
throw new BusinessException("用户名已被占用");
}
User user = new User();
user.setUsername(request.getUsername());
// 加密存储密码
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole("USER");
user.setStatus(1);
userMapper.insert(user);
}
}
BCrypt加密后的字符串长度是固定的60位,存入数据库的字段长度记得给够(64位够了)。有些数据库默认建的varchar(32)长度根本存不下,插入数据时直接报Data truncation错误。
3.3 认证拦截过滤器
有了Token生成,还要有Token校验。SpringBoot中实现请求过滤有两种主流方式:HandlerInterceptor和OncePerRequestFilter。两者机制不同,使用场景也有区别。
HandlerInterceptor是Spring MVC层面的拦截器,只拦截进入DispatcherServlet的请求,对静态资源默认不拦截,可以精确控制哪些URL需要拦截,适合做细粒度的请求前置处理。OncePerRequestFilter是Servlet层面的过滤器,在请求进入Servlet容器后、进入DispatcherServlet前执行,所有请求都会经过它,适合处理跨切面的全局逻辑。
对于JWT认证,官方推荐的还是OncePerRequestFilter(或者Spring Security里的Filter)。原因是它在Spring Security过滤器链中配置更自然,且能确保一次请求只执行一次过滤逻辑。下面是我在实际项目里封装的一个JwtAuthenticationFilter:
java复制@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
private JwtUtil jwtUtil;
@Resource
private UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.getTokenFromRequest(request);
// 请求头中没有Token,放行,后续由Spring Security或拦截器处理
if (token == null) {
filterChain.doFilter(request, response);
return;
}
try {
// 解析Token,获取用户信息
Claims claims = jwtUtil.parseToken(token);
Integer userId = claims.get("userId", Integer.class);
String role = claims.get("role", String.class);
// 从数据库加载用户信息,确认用户仍然有效
User user = userService.findById(userId);
if (user == null || user.getStatus() != 1) {
// 用户不存在或被禁用,直接返回401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"用户不存在或已被禁用\"}");
return;
}
// 构造认证信息,放入SecurityContext
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"Token已过期\"}");
return;
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"Token无效\"}");
return;
}
filterChain.doFilter(request, response);
}
}
过滤器里做得比较重的一步是从数据库重新查了一次用户信息。有人觉得这么做违背了JWT无状态的初衷,但从安全性角度考虑,这能确保用户被禁用、删除、修改角色后,旧Token立即失效,而不是等到Token过期。对于管理后台这类对权限变更敏感的系统,这点牺牲有必要。
如果项目对性能要求极高、用户量很大,可以把第二步改为只查Redis缓存,或者干脆信任Token中的角色信息。这是个取舍问题,根据业务安全级别来定。
3.4 配置SecurityConfig放行白名单
配置Spring Security的过滤器链时,需要把登录、注册、验证码等接口加入白名单,其他的接口统一走JWT认证:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Resource
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 放行登录注册和Swagger文档
.antMatchers("/api/auth/login", "/api/auth/register").permitAll()
.antMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// 静态资源
.antMatchers("/static/**", "/favicon.ico").permitAll()
// 其他所有请求需要认证
.anyRequest().authenticated()
.and()
// 没有认证时返回401
.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已失效\"}");
});
// 把JWT过滤器加到用户名密码过滤器之前
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
我在集成Swagger时踩过版本兼容的坑:SpringBoot 2.x里用的是springfox 3.0.0,或者springdoc-openapi-ui 1.6.x。如果SecurityConfig中没放行/swagger-ui, 和/v3/api-docs, 浏览器打开Swagger页面时会提示401,光看页面空白,很容易误判为依赖配置出错。热词里有人搜“springboot jwt 放开swagger”,就是这个场景。把Swagger相关路径加入白名单后,还要注意API文档的调试请求也要能携带Token,建议在Swagger中配置全局Authorization头,这样调试受保护接口更方便。
3.5 自定义注解实现接口级鉴权
认证解决了“你是谁”,接下来处理“你能干什么”。如果只是登录就能访问所有接口,那权限控制等于没做。在项目中我习惯用自定义注解+拦截器的方式实现接口级权限控制,比在Service层写一堆if判断干净很多。
先定义一个注解:
java复制@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
// 需要的角色,不填则只要登录即可
String[] value() default {};
}
然后在需要权限控制的Controller方法上加注解:
java复制@RequirePermission("ADMIN")
@GetMapping("/admin/user/list")
public Result listUsers() {
// 只有ADMIN角色能访问
return Result.success(userService.listAll());
}
最后在拦截器或AOP中做逻辑判断:
java复制@Component
public class PermissionInterceptor implements HandlerInterceptor {
@Resource
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequirePermission annotation = handlerMethod.getMethod().getAnnotation(RequirePermission.class);
if (annotation == null) {
return true;
}
// 从SecurityContext获取当前登录用户
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
return false;
}
// 校验角色
String[] requiredRoles = annotation.value();
if (requiredRoles.length > 0) {
boolean hasRole = false;
for (String role : requiredRoles) {
if (authentication.getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals("ROLE_" + role))) {
hasRole = true;
break;
}
}
if (!hasRole) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"没有权限访问\"}");
return false;
}
}
return true;
}
}
这种注解方式的好处是权限规则直接声明在接口方法上,看代码的时候一眼就能知道这个接口谁能用,不用翻配置文件。缺点是权限表达不够灵活,复杂规则(比如“部门经理或以上”这种层级关系)还是要在代码里细化。对于大多数中小项目,注解+角色字符串已经够用。
4. 鉴权扩展与安全加固
4.1 Token过期与自动续签
JWT一个绕不开的问题是:Token过期了怎么办?让用户重新登录一次体验太差,给一个超长有效期又增加安全风险。常规方案是引入RefreshToken机制,双Token配合。
主Token(AccessToken)有效期短,比如2小时,用于业务请求的认证。RefreshToken有效期长,比如7天或30天,专门用来换取新的AccessToken,只走一个刷新接口,不能访问业务接口。用户AccessToken过期后,前端拿到401响应,自动调用刷新接口,带上RefreshToken换取新的AccessToken,整个过程用户无感知。
刷新接口的实现思路:
java复制@PostMapping("/refresh")
public Result refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
// 校验RefreshToken是否合法且未过期
if (!jwtUtil.validateToken(refreshToken)) {
return Result.error(401, "RefreshToken无效或已过期,请重新登录");
}
Claims claims = jwtUtil.parseToken(refreshToken);
Integer userId = claims.get("userId", Integer.class);
User user = userService.findById(userId);
if (user == null) {
return Result.error(401, "用户不存在");
}
// 生成新的AccessToken和RefreshToken
String newAccessToken = jwtUtil.generateToken(userId, user.getUsername(), user.getRole());
String newRefreshToken = jwtUtil.generateRefreshToken(userId, user.getUsername(), user.getRole());
return Result.success(Map.of("token", newAccessToken, "refreshToken", newRefreshToken));
}
刷新Token时有一个需要特别注意的安全细节:是否把旧Token列入黑名单。如果RefreshToken泄露了,攻击者可以在有效期内一直刷新。为了降低风险,每次刷新后让旧RefreshToken立即失效更好,实现上可以用Redis记录已失效的RefreshToken,过期时间与RefreshToken一致。
我实际项目中用了另一种轻量方案:RefreshToken里带一个自增的tokenVersion字段。用户表里存一个token_version,每次刷新+1,服务端校验RefreshToken里的version是否等于数据库里的当前值,不等说明已经被使用过,直接拒绝并主动让该用户所有Token失效。这个方案不用Redis,简单场景下够用。
4.2 防伪造与防篡改加固
JWT最常见的攻击手段是伪造。攻击者不知道服务端密钥,就不可能生成合法的签名。但如果密钥泄露或者太弱,一切防御都形同虚设。密钥管理这块有几个实操经验:
第一,密钥长度必须满足算法要求。HS256要求密钥至少256位(32字节),低于这个长度jjwt会抛异常。别再用手敲的短字符串了,可以用openssl rand -base64 48生成一个足够长的随机密钥。
第二,生产环境的密钥必须走配置中心或环境变量,不要写到代码仓库。哪怕你的仓库是私有的,一旦员工离职、代码泄露,密钥就废了。Git提交历史里也不要有,已经提交过的要立刻轮换。这个教训我在实际项目里深刻体验过。
第三,防止算法混淆攻击。JWT的Header里带了一个alg字段,一些老版本的JWT库在验签时不校验算法类型,攻击者可以把HS256改成none,或者把非对称算法降级为对称算法。现在的jjwt 0.11.x版本默认启用了算法白名单,但还是建议在解析时明确指定算法:
java复制Jwts.parserBuilder()
.setSigningKey(getSecretKey())
// 明确限制只接受HS256
.requireAlgorithm("HS256")
.build()
...
第四,JWT的Payload不是加密的,任何人都可以用base64解码看到内容。所以Token里不要放密码、身份证号、手机号、邮箱等敏感信息。最多放用户ID和角色这两个非敏感、但业务请求中频繁用到的字段。其他用户详情通过接口查询。
4.3 越权防护:水平越权和垂直越权
JWT认证体系里最隐蔽的安全漏洞其实是越权。越权分两种:水平越权和垂直越权。
水平越权:用户A能看到或操作用户B的数据。典型场景是查询订单详情接口,Controller里直接用了前端传过来的orderId和userId。攻击者把userId参数改成别人的,就能看到别人的订单。正确的做法是,请求中的userId一律从Token中取,前端传的userId直接忽略。订单归属校验也必须在服务端做:查完订单后判断订单的userId和Token里的userId是否一致,不一致就拒绝。
垂直越权:普通用户能访问管理员接口。这个问题靠上面的注解鉴权基本能覆盖。但要注意一个坑:如果SecurityConfig里配置了anyRequest().authenticated(),只拦截了未登录的请求,已登录的普通用户访问/admin/**还是会放行到Controller,如果Controller里没有权限校验,就产生了垂直越权。解决方案是让SecurityConfig在URL层面也做角色控制:
java复制.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
把URL级别的角色控制在SecurityConfig里声明,Controller里的注解作为二次防线。双重保险,出问题的概率就低很多。
4.4 账号多端登录与单点登录
JWT无状态带来的一个副作用是不好管理“谁在哪儿登录”。Session方案下,服务端可以轻松踢人下线,把Session删掉就行。JWT就麻烦了,Token在客户端手里,服务端这边没有存储,没法主动让它失效。
如果业务有“同一账号只允许一个设备登录”或“管理员可以强制下线用户”的需求,纯JWT方案是玩不转的。我的做法是引入Redis做一个Token黑名单或Token白名单:
- 白名单方案:用户登录时,把Token存入Redis,Key规则是
login:token:{userId}。每次请求校验时,除了验证Token本身合法,还要查一下Redis里存的是不是当前这个Token。踢人下线时直接把Redis里的Token删掉,后面这个Token再来就失效了。 - 黑名单方案:登出或强制下线时,把Token加入Redis黑名单,过期时间等于Token剩余有效期。请求来时先查黑名单,在名单里就拒绝。
这两种方案都在无状态的基础上加了一点状态,但换来的是可控性。具体选哪种看业务需要。对管理后台这种需要强管控的系统,我更推荐白名单方案,实现逻辑直观,还能顺带统计在线用户数。
5. 踩坑记录与前后端联调实战
5.1 常见问题速查表
整理了这段时间以来在实际项目中排查过的一批高频问题,基本上遇到一个解决一个,现在把解决方案沉淀成表格:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 启动报WeakKeyException | HS256密钥长度不足32字节 | 生成随机长密钥,不要手敲短字符串 |
| Token解析报JWTDecodeException | Token格式损坏或前端多传了空格 | 统一用Bearer 前缀解析,注意去掉前后空格 |
| 前端带Token仍返回401 | 请求头Authorization写错,或过滤器没生效 | 确认请求头名称前后端一致,过滤器注册到SecurityConfig |
| 接口一直提示未登录 | CORS跨域配置里没暴露Authorization响应头 | 在CORS配置中用exposedHeaders("Authorization") |
| 登录成功后Token能用,但修改密码后旧Token还能用 | 服务端没有版本校验机制 | 引入token_version或黑名单机制 |
| 集成Swagger后文档页面无法打开 | SecurityConfig没放行Swagger路径 | 添加swagger相关路径到白名单 |
| SpringBoot 3.x下编译报javax.*不存在 | javax迁移到jakarta包名 | 升级依赖包,或回到SpringBoot 2.7.x |
| 用户角色修改后旧Token仍显示旧角色 | 角色信息直接写在Token里 | 在过滤器中重新查库获取最新角色 |
这个表基本覆盖了JWT集成过程中80%以上的问题。前四个偏联调阶段,中间三个偏架构阶段,最后一个偏业务正确性阶段。如果项目出现问题,对照着排查能省不少时间。
5.2 前端联调中的几个关键约定
JWT方案前后端联调,最容易扯皮的地方就是Token传递方式。前后端必须从一开始就约定好以下三条:
第一,Token放在请求头的Authorization字段,格式为Bearer <token>。注意Bearer后面有个空格,前端拼接的时候要写对。常见错误是只传了token字符串没带Bearer前缀,后端解析时按前缀切分,拿到的就是原始字符串,校验会失败。
第二,前端要统一封装一个HTTP请求拦截器。所有请求自动从本地存储取出Token并塞到请求头里,同时统一处理401响应。收到401说明Token过期或无效,自动调用刷新接口获取新Token,然后重放原请求。如果刷新也失败了,跳转登录页。
第三,Token存储位置建议用localStorage不要用Cookie。Cookie会自动携带,容易引入CSRF风险;localStorage避免了自动携带,配合自定义请求头使用更安全。虽然localStorage有XSS读取的风险,但只要做好输入输出转义,综合风险比Cookie方案小。
5.3 大文件上传接口怎么带Token
项目里有一个上传大文件的接口,最初给文件上传接口放行了认证,想着文件流处理逻辑里没有用户上下文。结果被人利用来刷流量,后来还是把上传接口纳入认证体系,发现了一个Spring Security的坑:文件上传走的是multipart/form-data请求,请求头里的Authorization字段是正常能带上的,但Spring Security对multipart请求的处理有个小坑,如果spring.servlet.multipart.max-file-size配置过大,请求体在过滤器中默认不会被提前解析,导致JWT过滤器拿到Token后重新查库失败。
排查下来根本不是Token的问题,而是过滤器链和multipart解析器的执行顺序。解决方案是在SecurityConfig里显式注册MultipartFilter到过滤器链之前,或者在上传接口的鉴权方式上改成先获取Token再做base64解析。这里不展开细说,碰到类似现象时可以从过滤器顺序入手排查。
还有一个常见的SpringBoot配置问题是上传文件大小限制,如果只是调大了max-file-size而不调max-request-size,大文件上传时可能会在请求解析阶段就被拒绝,表现为504超时或Connection Reset。做文件上传模块时,这两个参数必须一起调整。
5.4 关于Redis缓存用户信息的优化
前面提到在JWT过滤器中重新查库获取用户信息,这个逻辑在小中项目里没什么问题,但用户量大了之后,每个请求都来一次数据库查询,对数据库压力还是很大的。
优化方案:把用户基本信息(ID、用户名、角色、状态)作为Hash结构缓存到Redis,Key是user:info:{userId},过期时间5分钟。过滤器里先从缓存取,缓存没有再去查库,查完回填缓存。这样数据库的QPS压力就从“每个请求”变成了“每个用户每5分钟一次”。
查询频率能下降几个量级。我经历过一个项目,用户量5万,日活8000,上线前是全量查库,压测时数据库CPU直接飙到90%。加上Redis缓存后降到了15%左右。对于JWT无状态方案来说,这是最常见的数据库压力来源,提前做缓存设计很有必要。
6. 这套方案还能怎么扩展
前面讲的是一套完整的JWT认证鉴权落地,从登录签发Token,到请求拦截验签,再到接口级权限控制,以及防过期、防越权的加固措施。实际生产项目里,这套体系还可以顺着几个方向继续延伸。
如果项目是微服务架构,可以把JWT的密钥统一放到配置中心,各个服务持有同样的密钥就能校验同一个Token。用户在一个服务里登录,在其他服务里直接带着Token访问,不用重复登录,这就是单点登录的轻量版实现。
如果项目对安全要求更高,可以引入OAuth2.0或OIDC协议,把JWT作为其中一种Token载体。Spring Authorization Server或者Sa-Token、JustAuth这类框架都值得研究。它们的核心还是JWT,只不过在上层把授权码流程、Client管理、Scope校验这些企业级能力补齐了。
如果项目需要更精细的权限模型,比如基于角色的权限(RBAC)不够用了,可以在JWT里只放角色,权限点通过Redis或数据库动态加载。Spring Security的@PreAuthorize注解配合SpEL表达式,可以做到方法级细粒度控制,比如@PreAuthorize("hasAuthority('order:delete')")。这种“角色-权限”两级模型的扩展性比只存角色字符串强很多。
我在实际项目里体会最深的一点:JWT本身只是一个安全载体,它解决的是“身份凭证怎么可靠传递”的问题。认证和鉴权的核心还是业务逻辑本身——你的用户体系设计是否合理,权限模型是否清晰,敏感接口的保护是否到位。工具用得再熟练,这些基本功不扎实,系统该出漏洞还是出漏洞。
