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月前
|
Java 应用服务中间件 Maven
5-微服务篇-1
本文详解SpringBoot核心机制:①自动装配原理——通过@SpringBootApplication→@EnableAutoConfiguration→AutoConfigurationImportSelector→spring.factories加载条件化配置类;②启动流程三阶段:初始化、上下文创建、自动配置;③常用起步依赖、配置文件类型(properties/YAML)及加载优先级;④四种运行方式。
404 23
|
1月前
|
缓存 API 开发工具
通义千问Qwen大模型深度解析:Qwen3.8‑Max/Flash/Omni能力矩阵,RAG微调、智能体开发、代码调用实战、与企业落地完整指南
生成式AI已经从简单问答对话,进化为可以处理超长文档、图文音视频混合输入、自主拆解目标完成多步骤业务的通用智能底座。通义千问Qwen3.8完整家族形成了分层能力矩阵,覆盖旗舰推理、均衡通用、高速高吞吐、原生全模态视听、专项编程多类基座,既可以普通用户网页端直接交互体验,也可以通过百炼平台API集成进业务系统,同时开放开源权重,支持本地私有化部署,覆盖个人创作者、独立开发者、中小企业、大型政企的差异化诉求。
407 0
|
2月前
|
机器学习/深度学习 数据采集 人工智能
企业知识库搭建实战:RAG 从文档导入到检索调优全流程拆解
本文详解企业知识库搭建实战:以RAG为核心,覆盖文档导入、智能解析分段、语义/增强检索调优全流程。结合硅基边界平台案例,直击解析策略、分段长度、相似度阈值等关键参数调优要点,助技术/产品/运营团队两周内快速验证AI问答效果,让私域资料真正变成“会回答的AI”。
457 1
|
2月前
|
人工智能 中间件 定位技术
LangChain 入门教学:一张地图搞懂模型、链、RAG、图与智能体
本文是一份面向初学者的LangChain系统性入门指南,以“认知地图”为主线,清晰梳理LangChain 1.0、LangGraph、RAG、智能体(`create_agent`)等核心模块的定位与协作关系,摒弃过时API,聚焦2026年官方推荐架构,助开发者快速建立正确心智模型。(239字)
741 0
|
6月前
|
人工智能 Java API
【SpringAIAlibaba新手村系列】(13)Tool Calling 函数工具调用技术
本文详细解析 Spring AI 的 Tool Calling 技术,阐明其如何弥补大模型“会说不会做”的局限。通过 @Tool 注解,开发者可轻松将 Java 方法暴露为 AI 工具。文中深入讲解了 ToolCallbacks.from() 注册工具的原理,以及工具方法在当前 Spring Boot 进程内通过反射动态执行的底层逻辑,强调了模型决策与框架执行的协同过程,为理解 AI 赋能实际操作奠定基础。
1570 0
|
10月前
|
消息中间件 Java Nacos
SpringCloud概述
Spring Cloud是微服务的统一解决方案,具备注解驱动、开箱即用、组件丰富等特点,通过版本命名规范整合多子项目。Spring Cloud Alibaba融合Nacos、Sentinel、Seata等阿里开源组件,成为主流技术栈选择。
|
存储 安全 Java
2025 年一线互联网大厂最新高质量 Java 面试八股文整理及答案汇总
本文整理了一线互联网大厂最新的高质量Java面试八股文及其答案,涵盖Java基础、集合框架与多线程三大核心模块。内容包括面向对象与面向过程的区别、`equals`与`==`的差异、`final`和`static`的用法、集合类如`ArrayList`与`LinkedList`的对比、`HashMap`的工作原理及其与`Hashtable`的区别,以及多线程中的线程创建方式、生命周期、上下文切换和死锁等知识点。通过系统化的梳理与解析,帮助读者高效备考Java面试,掌握核心技术要点。资源可从文末链接下载。
3250 40
|
机器学习/深度学习 自然语言处理 监控
命名实体识别(Named Entity Recognition, NER)
命名实体识别(NER)是自然语言处理的重要任务,旨在从文本中识别并分类特定实体,如人名、地点、组织等。通过BIO等标注模式,利用HMM、CRF及深度学习模型如RNN、LSTM、Transformer等进行实体识别。预训练模型如BERT显著提升了NER的性能。NER广泛应用于新闻分析、生物医学等领域,是信息提取、知识图谱构建等任务的基础。
2333 3
|
图形学
unity噪声消融效果Shader实现
在 Unity 中实现噪声消融效果,利用噪声纹理和透明度裁剪技术。噪声纹理(如 Perlin 噪声)为物体表面提供随机参考值,透明度裁剪通过设置阈值控制显示与消失。具体步骤包括引入噪声纹理、设置阈值和边缘颜色,使用自定义着色器代码实现物体部分消失的视觉效果。通过调整材质球和噪声贴图,可优化最终呈现。
|
缓存 前端开发 网络协议
性能优化|几个方法让图片加载更快一些
对电商网页的性能而言,图片优化是至关重要的事情,本文就此探讨了一些简单、可靠的图片优化手段。