JWT的四种设计策略------轻量负载、缓存外置、上下文线程、统一验证
JWT登录不是"把token发出去就完事了"。怎么存、存多少、验证后怎么加载完整信息、权限怎么验------这四个决策决定了你的认证系统是轻量还是臃肿、安全还是漏洞。这篇文章以一个生产环境的JWT实现为例,拆解这四个设计策略。
文章目录
一、为什么JWT本身要尽量轻
JWT的负载在每次请求时都会被传输------Header里塞的token越长,每个HTTP请求就多占一截网络带宽。如果你把用户的所有信息全存进token里------用户名、身份证号、机构名称、角色列表、权限地图------这个token可能几百个字符长,每次请求都要带着它从客户端到网关到服务端,全链路传输。
策略一:JWT负载只存最少必要信息。
java
// 生成Access Token------只放三个字段
public String generateAccessToken(String psnId, String psnName, String unitId) {
return Jwts.builder()
.setSubject(psnId) // 唯一标识
.claim("psnId", psnId)
.claim("psnName", psnName) // 显示用的名字
.claim("unitId", unitId) // 所属机构
.claim("type", "access")
.setIssuer("browise")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessExpiry))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
只放三个字段:用户ID、显示名、机构ID。完整的用户信息、角色列表、权限清单全部不在token里------token只是一个钥匙,不是房子。
token里不放敏感信息------身份证号、密码hash、手机号这些永远不放进JWT的Claims。token在网络上传输,虽然是HTTPS加密的,但一旦被截获,解密后的负载就会暴露。负载越轻,暴露面越小。
二、完整信息存哪里------缓存外置
Token只验证"你是谁",但权限判断需要"你能做什么"。用户完整信息、机构树、角色列表、菜单权限------这些不能放token里,也不能每次都查数据库。
策略二:JWT验证通过后,从缓存加载完整用户信息。
java
// CacheManager------加载完整用户档案
public UserProfile getUserProfile(String psnId) {
// 先查缓存
UserProfile cached = cache.get(psnId);
if (cached != null) return cached;
// 缓存未命中------查数据库
// 1. 查 OP_PERSON + OP_UNIT → 姓名、机构、状态
// 2. 查 SYS_USER_ROLE → 角色列表
UserProfile profile = new UserProfile();
profile.setPsnId(psnId);
profile.setPsnName(personMapper.selectByPsnId(psnId).getPsnName());
profile.setUnitId(personMapper.selectByPsnId(psnId).getUnitId());
profile.setRoles(roleMapper.selectByPsnId(psnId));
cache.put(psnId, profile, ttl);
return profile;
}
两点关键设计:
缓存优先。用户每次请求都走JWT验证→取psnId→从缓存加载完整档案。缓存命中时不需要查数据库。这在每次请求都执行的Filter里是绝对性能保障------一个HTTP请求从认证到权限验证到业务处理,中间的认证环节不能慢。
缓存兜底。如果缓存未命中(刚重启、缓存过期),自动回源查数据库。下次请求就命中缓存了。这个机制保证了缓存在正常时的高性能,同时在异常时不会阻塞用户请求。
三、验证后的信息放哪里------当前请求线程内全局可用
JWT验证通过、完整档案加载完成之后,这些信息需要在本次请求的任何地方都能拿到------Controller、Service、DAO、AOP切面------不需要每个方法签名都加一个 UserProfile 参数。
策略三:验证通过后,用户信息存入ThreadLocal,请求结束时清理。
java
// JwtAuthFilter 核心逻辑
try {
// 1. 从Header取token → 解析 → 拿到psnId
String token = request.getHeader("Authorization");
String psnId = jwtUtil.getSubject(token);
// 2. 检查黑名单(是否被踢下线、是否被强制失效)
if (jwtBlacklist.isBlacklisted(psnId, token)) {
throw new AuthException("Token已失效");
}
// 3. 从缓存加载完整用户档案
UserProfile profile = cacheManager.getUserProfile(psnId);
// 4. 检查用户状态(是否被禁用)
if (!profile.isEnabled()) {
throw new AuthException("用户已被禁用");
}
// 5. 写入ThreadLocal------本次请求全局可用
CurrentUser.set(profile);
// 6. 权限校验------这个路径当前用户能否访问
if (isProtected(path) && !menuAuthProvider.canAccess(profile, path)) {
throw new AuthException("无权访问");
}
filterChain.doFilter(request, response);
} finally {
// 请求结束时清理ThreadLocal------防止线程池复用造成信息泄漏
CurrentUser.clear();
}
finally 里的 CurrentUser.clear() 是安全底线。Tomcat的线程池会复用线程------上一个请求的用户信息残留在ThreadLocal里,下一个请求分配到同一个线程时不清理,可能读到最后一个人的数据。不是你想太多,是线程池的物理特性决定了必须这么做。
四、权限统一验证------不在Controller里一个一个写
不要在业务代码里写权限判断------同一个业务的增删改查分散在多个Controller里,每个判断手动写一次,漏一个就是越权漏洞。
策略四:权限校验集中在后端,不用前端校验替代。
两套机制互补:
路径级校验------在Filter里完成,不进入业务代码:
java
// MenuAuthProvider------预加载角色→菜单映射
public boolean canAccess(UserProfile profile, String path) {
// roleMenuMap:每10分钟从 SYS_ROLE_MENU + SYS_MENU 加载一次
// 查缓存:该用户的角色集合能否访问这个路径
return profile.getRoles().stream()
.anyMatch(role -> {
Set<String> allowedPaths = roleMenuMap.get(role);
return allowedPaths != null && allowedPaths.stream()
.anyMatch(p -> path.startsWith(p));
});
}
方法级校验------通过注解控制数据权限:
java
@DataScope(type = DataScopeType.SELF) // 只能看自己的数据
public List<Record> queryMyRecords() { ... }
@DataScope(type = DataScopeType.UNIT) // 只能看本机构的数据
public List<Record> queryUnitRecords() { ... }
@DataScope 注解被AOP切面拦截,根据scope类型自动生成SQL的WHERE条件------PSN_ID = 当前用户 或 UNIT_ID = 当前机构。业务代码不需要自己拼SQL权限条件,注解决定了查询范围。加一个注解就自动过滤,去掉注解就全量查询------权限控制从业务代码中剥离出来。
五、附赠:Token黑名单------踢人下线和防止重放
Access Token签发后无法撤销------JWT本身没有服务端状态。但如果需要强制下线、用户改密码后让旧token失效,必须有一个黑名单机制:
java
// JwtBlacklist------两种失效策略
// 1. 按时间戳失效:mass-invalidate该用户某个时间点之前签发的所有token
public void invalidateByTime(String psnId, long timestamp) {
invalidateTimes.put(psnId, timestamp);
}
// 2. 按Token本身失效:单个token被加入黑名单(用户主动退出登录)
public void invalidateByToken(String token) {
tokenBlacklist.add(hash(token));
}
按时间戳失效 处理"改密码后所有旧token失效"------不是逐个查token列表,是记录一个时间点,该用户在这个时间点之前签发的所有token全部无效。按token失效处理"用户主动退出登录"------单个token加入黑名单,只失效这一个。两种机制混合使用,覆盖了不同场景下的失效需求。
六、四种策略总结
| 策略 | 做法 | 解决什么问题 |
|---|---|---|
| 轻量负载 | JWT只存psnId+psnName+unitId | 减小网络传输、降低泄露风险 |
| 缓存外置 | 完整信息存JVM缓存,先查缓存再查数据库 | token不挑重担,每次请求不查库 |
| 上下文线程 | 验证后set进ThreadLocal,finally清理 | 请求链路内全局可用,不层层传参 |
| 统一验证 | Filter路径校验 + 注解数据权限 | 业务代码不写权限判断 |
七、结语
JWT认证的设计,核心不是"怎么签发token"------这个网上有上万个教程。核心是签发之后怎么处理:
Token里存最少信息------只做身份标识,不做信息载体。验证通过后从缓存加载完整档案------token是钥匙,缓存是房间。用户信息放ThreadLocal------一次请求全局可用,请求结束清理。权限校验集中在后端------不在业务方法里写if判断,不依赖前端校验是否传了正确的人。
这四条定下来,认证系统的骨架就稳了。