本文是「成都硅基边界」零代码构建平台技术复盘。前面几篇讲的是 Python 侧的 AI 引擎(智能体、RAG、MCP、工作流),这一篇换到 Java 侧的业务后端,聊一个几乎所有后端都会遇到、但很少有人真正想清楚的问题:JWT 和 Redis 会话,到底该用哪个?
我们的答案是:两个都要用,各管一件事。文中代码均为示意代码(根据设计改写、非项目真实源码),按生产级 Java 规范书写,签名密钥等敏感信息已脱敏。
一、背景:JWT 的"不能撤销"困境
先说一个大家都踩过的坑:用纯 JWT 做登录态,用户点"退出登录"之后,那个 token 依然是有效的。
这不是实现 bug,而是 JWT 的设计本质——它是自包含的(self-contained):签名、过期时间、用户信息全在 token 里,服务端不需要存任何东西就能验证。代价就是:
| 纯 JWT 的问题 | 后果 |
|---|---|
| 签发即有效,直到过期 | 无法强制下线,登出只是前端删了本地 token |
| 状态冻结在签发那一刻 | 账号被禁用、权限被回收,token 里还是旧的 |
| 想要"即时生效" | 只能缩短过期时间 → 用户频繁重新登录 |
| 想要"能撤销" | 还是得引入黑名单 → 又回到有状态 |
反过来,纯 Redis 会话也有它的短板:每个请求都要踢一次 Redis(中心化、有网络开销),而且请求本身不自带身份信息,横跨服务时要反复查。
所以正确的做法不是二选一,而是把职责拆开:
JWT 负责"你是谁"(身份声明,无状态、可本地校验);
Redis 负责"你是否还有效"(会话状态,有状态、可即时撤销)。
两者叠加后,一次登录的真实有效期 = min(JWT 的 exp, Redis 会话的 TTL)——这个关系后面会展开讲,因为它是最容易被误解的地方。
二、整体架构:三层防线 + 三端隔离
平台有三类终端(企业端 TENANT、管理端 ADMIN、代理端 PROXY),统一从网关进入
三层防线各司其职:网关挡在最外层(成本最低、拦掉绝大多数无效请求)、服务层查数据库状态(防内网直连绕过网关 + 补齐状态变更)、注解做细粒度授权。
三、第一层:JWT 承载身份
签发时把身份信息写进 claims,并明确有效期:
/**
* JWT 令牌组件:负责令牌的签发与签名校验。
*/
@Component
@RequiredArgsConstructor
public class JwtTokenComponent {
/** 签名密钥,生产环境应来自配置中心/环境变量,禁止硬编码(见第八节) */
private final SecretKeySpec signKey;
private final RedisSessionStore sessionStore;
/** 令牌有效期(天)——注意与会话 TTL 的关系,见第四节 */
private static final int TOKEN_EXPIRE_DAYS = 30;
/**
* 签发令牌,并同步写入会话存储。
*
* @param principal 登录主体(账号、公司、平台等身份信息)
* @return 带 Bearer 前缀的完整令牌
*/
public String login(LoginPrincipal principal) {
String token = BEARER_PREFIX + Jwts.builder()
.issuer(ISSUER)
.subject(SUBJECT_LOGIN)
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plus(TOKEN_EXPIRE_DAYS, ChronoUnit.DAYS)))
.claim("id", principal.accountId())
.claim("phone", principal.phone())
.claim("personId", principal.personId())
.claim("platformCode", principal.platform().getValue())
.claim("companyId", principal.companyId())
.signWith(signKey) // HS384
.compact();
sessionStore.save(principal, token); // 第二层:写入 Redis 会话
return token;
}
/**
* 校验签名与过期时间,解析出登录主体。
*
* @param token 不含 Bearer 前缀的令牌
* @return 校验通过返回登录主体;签名错误或已过期返回 {@link Optional#empty()}
*/
public Optional<LoginPrincipal> verify(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(signKey)
.build()
.parseSignedClaims(token) // 签名不匹配 / 已过期会直接抛异常
.getPayload();
return Optional.of(LoginPrincipal.from(claims));
} catch (JwtException | IllegalArgumentException e) {
log.warn("令牌校验失败: {}", e.getMessage());
return Optional.empty();
}
}
}
这里的设计要点:
- claims 里只放身份标识,不放权限明细——权限每次从数据库/缓存取,避免"权限变更后 token 里还是旧权限";
- 只放必要字段(账号、企业、平台),因为 JWT 是 Base64 编码而非加密,任何人可读——敏感信息绝不能进去;
- 校验失败统一返回
Optional.empty(),不向上层抛异常,让调用方决定如何处理(网关拒请求、服务层抛业务异常)。
四、第二层:Redis 承载会话(登出即失效的关键)
这一层才是"可撤销"的来源:
/**
* Redis 会话存储:登录写入、登出删除,key 按「平台 + 账号」维度隔离。
*/
@Component
@RequiredArgsConstructor
public class RedisSessionStore {
/** 会话有效期(秒):7 天 */
private static final long SESSION_TTL_SECONDS = 604_800L;
private final StringRedisTemplate redis;
/** 会话 key:login:{平台}:{账号ID} */
private static String sessionKey(PlatformEnum platform, Long accountId) {
return "login:%s:%d".formatted(platform.name(), accountId);
}
/** 登录:写入会话记录(同平台重复登录会覆盖,见第六节讨论) */
public void save(LoginPrincipal principal, String token) {
redis.opsForValue().set(
sessionKey(principal.platform(), principal.accountId()),
token,
Duration.ofSeconds(SESSION_TTL_SECONDS));
}
/** 校验会话是否存在 */
public boolean exists(PlatformEnum platform, Long accountId) {
return Boolean.TRUE.equals(redis.hasKey(sessionKey(platform, accountId)));
}
/**
* 登出:删除会话记录。
* <p>JWT 本身无法撤销,但会话记录一旦删除,网关的 Redis 校验就会拒绝该令牌——
* 这就是"登出即时生效"的实现原理。
*/
public void logout(PlatformEnum platform, Long accountId) {
redis.delete(sessionKey(platform, accountId));
}
}
这里藏着一个最容易踩的认知坑:JWT 的 exp 设了 30 天,但 Redis 会话 TTL 只有 7 天。很多人会以为 token 能活 30 天,实际上——
令牌真正的可用时长 = min(JWT exp, Redis TTL) = 7 天。
7 天后 Redis key 过期,网关的会话校验失败,token 立刻失效,尽管它自己还以为能再活 23 天。
这个"取小值"的关系不是缺陷,而是一种刻意设计的缓冲带:JWT 的 exp 作为兜底上限(Redis 万一被清空,token 也不会永久有效),Redis TTL 作为实际的业务会话期(可以随时调整,甚至续期)。
五、为什么会话 key 必须带"平台"维度
会话 key 是 login:{平台}:{账号ID},而不是简单的 login:{账号ID}。这一个字段带来的能力差别很大:
- 同一账号在三端可独立在线:同一个运营人员,可以同时登录管理端和企业端,互不干扰;
- 登出互不影响:退出管理端不会把企业端的会话一起注销;
- 天然防止跨端越权:管理端的 token 拿去调企业端接口,因为
platformCode与请求头平台不匹配,直接拒绝。
配合的关键设计是平台头必填:
// 网关注入:平台标识缺失或非法,一律拒绝(不给默认值)
String platformHeader = request.getHeaders().getFirst(PLATFORM_HEADER); // WWW-Platform
if (StrUtil.isBlank(platformHeader)) {
return unauthorized(exchange, "缺少平台标识");
}
PlatformEnum platform;
try {
platform = PlatformEnum.valueOf(platformHeader);
} catch (IllegalArgumentException e) {
return unauthorized(exchange, ResponseEnum.UNAUTHORIZED.getMessage());
}
"不给默认值"是一个重要决策:如果平台头缺失时默认按企业端处理,那么攻击者只要不传平台头,就可能用企业端身份命中管理端接口。默认值在鉴权场景里几乎总是危险的。
六、三层防线:为什么不能只靠网关
很多团队认为"网关鉴权过了,后面就安全了"。我们额外保留了两道校验,各有各的理由:
| 防线 | 位置 | 校验内容 | 解决的问题 |
|---|---|---|---|
| 第一道 | 网关 | 白名单 → 平台头 → JWT 签名/过期 → Redis 会话存在性 | 最外层拦截,无效请求不进业务集群 |
| 第二道 | 业务服务 | JWT 再验签 + 数据库实体状态 | 内网直连绕过网关;Redis 存续期内账号被禁用 |
| 第三道 | 注解切面 | @ApiAuth(AccountTypeEnum[]) |
细粒度授权:该接口允许哪类账号访问 |
第二道防线最关键。看这段服务层的校验逻辑:
@Override
public LoginPrincipal verifyToken(String token, PlatformEnum platform) {
String raw = stripBearer(token);
LoginPrincipal principal = jwtTokenComponent.verify(raw)
.orElseThrow(() -> new BusinessException(ResponseEnum.UNAUTHORIZED));
// 关键:不只看 JWT,还要查实体当前状态
AccountPO account = accountService.getPO(principal.accountId());
if (account == null) {
throw new BusinessException(ResponseEnum.ACCOUNT_NOT_EXIST);
}
if (StatusEnum.DISABLE == account.getStatusEnum()) {
throw new BusinessException(ResponseEnum.ACCOUNT_DISABLE); // 禁用即时生效
}
CompanyPO company = companyService.getPO(account.getCompanyId());
if (StatusEnum.DISABLE == company.getStatusEnum()) {
throw new BusinessException(ResponseEnum.COMPANY_DISABLE);
}
return principal;
}
为什么必要?因为Redis 会话是"账号是否有过登录",不是"账号现在是否可用"。管理员刚把某个账号禁用,如果只信 Redis,这个用户还能继续用 7 天。查一次数据库状态,就把这个窗口关掉了。代理端同理——登录时代理期限校验、每次请求时代理状态校验,两层都要有。
七、两个必须处理的工程坑
坑一:ThreadLocal 存身份,Tomcat 线程复用会越权
服务层把当前登录人放进 ThreadLocal 供业务代码随时取用(避免层层传参),但 Tomcat 线程是复用的。如果某次请求因为异常没能走进清理逻辑,下一个复用到该线程的请求就可能读到上一个请求的身份信息——直接越权。
解法是"双向防御性清理":
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
// 进入时先清一次:防止上一请求异常残留(Tomcat 线程复用 → 越权风险)
JwtHolder.clear();
try {
// ... 解析身份并写入 JwtHolder
JwtHolder.setPrincipal(principal);
return true;
} catch (Exception e) {
JwtHolder.clear(); // 解析失败立刻清
throw e;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
JwtHolder.clear(); // 出口必清
}
规则:ThreadLocal 的清理要写成"入口先清 + 出口必清",绝不能只依赖出口。用 try-finally 或在拦截器的 afterCompletion 里清理,都是同理。
坑二:异步链路里拿不到请求上下文
WebSocket 消息处理、线程池任务、定时任务里,RequestContextHolder 是空的——如果这些链路里要调其他服务(Feign),鉴权头会丢失,对方直接 401。
解法是"上下文显式传递 + Feign 拦截器双路径兜底":
/** Feign 请求拦截器:优先用显式传递的上下文,兜底用当前请求头 */
@Bean
public RequestInterceptor authHeaderRelayInterceptor() {
return template -> {
// 路径一:异步/WebSocket 线程显式放入的上下文
Map<String, String> context = ThreadHeaderHolder.get();
if (!context.isEmpty()) {
context.forEach(template::header);
ThreadHeaderHolder.clear(); // 用完即清,避免污染线程池
return;
}
// 路径二:常规 Web 请求,从当前请求透传
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
template.header(HttpHeaders.AUTHORIZATION, request.getHeader(HttpHeaders.AUTHORIZATION));
template.header(PLATFORM_HEADER, request.getHeader(PLATFORM_HEADER));
template.header(ACCOUNT_ID_HEADER, request.getHeader(ACCOUNT_ID_HEADER));
}
};
}
两条路径缺一不可:常规请求靠 RequestContextHolder,异步链路靠显式传递。只做后者要改所有调用点,只做前者则异步场景必然掉鉴权。
八、自审:这套设计还有哪些可以做得更好
写完方案也要批评自己。以下是我们识别出的改进点:
- 签名密钥不该硬编码:密钥应放配置中心/环境变量,并支持轮换(如双密钥并行期),否则一旦泄露只能全局失效;
- exp 与会话 TTL 的"取小值"要写进文档:否则下一个接手的人会困惑"为什么 30 天的 token 七天就没用了";
- "单端单会话"能力已具备但未启用:会话存储里其实存了 token 值,只要在校验时多比对一次,就能实现"新登录踢掉旧登录"。我们当前选择允许多设备共存,这是产品决策而非技术限制——但必须明确记录,避免被误认为遗漏;
- 白名单的 glob 匹配要审慎:
PathMatcher的通配很容易写出过宽的规则(比如/ai/open/**顺手覆盖了不该放开的路径),白名单应当像防火墙规则一样 review; - 鉴权失败返回 HTTP 200 + 业务错误码:好处是前端只处理一套响应结构、也减少了对未授权接口的探测;坏处是网关层监控会失真(鉴权失败在 HTTP 指标里看不出来)。建议在网关单独打点统计"鉴权拒绝数",用业务指标补上这个盲区。
九、结语:鉴权设计的三条原则
把这次设计浓缩成三句话:
- 身份与状态分离:JWT 管"你是谁"(无状态、可本地校验),Redis 管"你是否还有效"(有状态、可即时撤销),谁也别越界去做对方的事;
- 校验要分层,越靠外越便宜:网关挡无效请求(只查 Redis 存在性)、服务层查数据库状态(应对状态变更与内网直连)、注解做细粒度授权——每层解决不同的问题,不要指望一层搞定;
- 所有"能撤销"的状态变更,都必须有一条即时生效的路径:登出、禁用、代理到期,用户和运营都期望"点一下立即生效",而不是"等 token 过期"。
"无状态"和"有状态"不是二选一的技术选型,而是一套分工方案。 想清楚谁负责什么,JWT + Redis 的组合才能既快又安全。