JWT + Redis 双层鉴权:为什么"无状态"和"有状态"必须一起用

简介: JWT 能验身份,却无法撤销——登出后令牌依然有效。本文复盘硅基边界 Silicon-Service 的 JWT + Redis 双层鉴权:JWT 承载身份(HS384 + claims),Redis 承载会话(login:平台:账号,TTL 7 天,登出即删),有效期为两者较小值;平台头必填实现三端隔离,网关、服务层、注解三层防线分工,并解决 ThreadLocal 越权与异步鉴权透传两个坑。

本文是「成都硅基边界」零代码构建平台技术复盘。前面几篇讲的是 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,异步链路靠显式传递。只做后者要改所有调用点,只做前者则异步场景必然掉鉴权。

八、自审:这套设计还有哪些可以做得更好

写完方案也要批评自己。以下是我们识别出的改进点:

  1. 签名密钥不该硬编码:密钥应放配置中心/环境变量,并支持轮换(如双密钥并行期),否则一旦泄露只能全局失效;
  2. exp 与会话 TTL 的"取小值"要写进文档:否则下一个接手的人会困惑"为什么 30 天的 token 七天就没用了";
  3. "单端单会话"能力已具备但未启用:会话存储里其实存了 token 值,只要在校验时多比对一次,就能实现"新登录踢掉旧登录"。我们当前选择允许多设备共存,这是产品决策而非技术限制——但必须明确记录,避免被误认为遗漏;
  4. 白名单的 glob 匹配要审慎PathMatcher 的通配很容易写出过宽的规则(比如 /ai/open/** 顺手覆盖了不该放开的路径),白名单应当像防火墙规则一样 review;
  5. 鉴权失败返回 HTTP 200 + 业务错误码:好处是前端只处理一套响应结构、也减少了对未授权接口的探测;坏处是网关层监控会失真(鉴权失败在 HTTP 指标里看不出来)。建议在网关单独打点统计"鉴权拒绝数",用业务指标补上这个盲区。

九、结语:鉴权设计的三条原则

把这次设计浓缩成三句话:

  1. 身份与状态分离:JWT 管"你是谁"(无状态、可本地校验),Redis 管"你是否还有效"(有状态、可即时撤销),谁也别越界去做对方的事;
  2. 校验要分层,越靠外越便宜:网关挡无效请求(只查 Redis 存在性)、服务层查数据库状态(应对状态变更与内网直连)、注解做细粒度授权——每层解决不同的问题,不要指望一层搞定;
  3. 所有"能撤销"的状态变更,都必须有一条即时生效的路径:登出、禁用、代理到期,用户和运营都期望"点一下立即生效",而不是"等 token 过期"。

"无状态"和"有状态"不是二选一的技术选型,而是一套分工方案。 想清楚谁负责什么,JWT + Redis 的组合才能既快又安全。

相关文章
|
7天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1781 10
|
11天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1643 3
|
12天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
8天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
778 2
|
6天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
802 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
|
20天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3965 5
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
11天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1155 0
|
13天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1510 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
6天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。

热门文章

最新文章