JWT的四种设计策略——轻量负载缓存外置上下文线程统一验证

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判断,不依赖前端校验是否传了正确的人。

这四条定下来,认证系统的骨架就稳了。

相关推荐
焦虑的说说19 小时前
秒杀系统设计方案
java
许彰午19 小时前
30_Java Stream流操作全解
java·windows·python
qq_25183645719 小时前
基于java Web网络订餐系统设计与实现 源码文档
java·开发语言·前端
小小工匠19 小时前
Redis 缓存替换策略:8 种淘汰策略与 LRU 实现剖析
数据库·redis·缓存
凡人叶枫20 小时前
Effective C++ 条款17:以独立语句将 newed 对象置入智能指针
java·linux·开发语言·c++·算法
飞天狗11120 小时前
零基础JavaWeb入门——第2课:让网页“活”起来 —— JSP是什么?
java·开发语言·前端·后端·web
2601_9619633820 小时前
技术解剖:哈希值、区块链与CA认证如何守护电子合同安全?
网络·人工智能·安全·区块链·智能合约·政务
梦@_@境20 小时前
面向 Spring Boot 的可观测业务流程编排引擎
java·spring boot·后端
科技林总20 小时前
解决vllm服务漏扫问题
python·安全
云烟成雨TD21 小时前
Spring AI Alibaba 1.x 系列【77】执行取消
java·人工智能·spring