这阵子帮一个团队改造老项目的登录模块,他们把用户状态全部放在服务端Session里,一到线上多实例部署就出问题,登录状态动不动就掉。后来我们用JJWT 0.11.5重写了认证这条链路,从依赖配置到工具类封装再到登录接口改动,整套流程走下来,坑也踩了不少。这篇就把完整过程整理出来,包括密钥怎么配、Token怎么生成和解析、登录接口怎么接、以及线上排查的几个典型问题,希望给正要接JWT的后端同学一份能直接上手的参考。
1. 为什么从Session切到JWT,以及什么场景下别硬上
1.1 Session认证的痛点:集群同步、移动端适配、跨域
先说说我们当时遇到的问题。原来那套系统用的是传统的Session认证方式:用户登录成功后,服务端把用户信息存在Session里,同时给客户端返回一个JSESSIONID,客户端后续请求带上这个ID,服务端再去Session里查对应状态。
单机部署的时候这套逻辑没毛病,但业务一旦起来,服务就需要横向扩容,一扩就出事了。请求被负载均衡分发到不同实例,A实例上创建的Session,B实例上查不到,用户就莫名其妙被登出。常见的解决方案是引入Session共享,把Session扔到Redis里,或者用Sticky Session让同一用户的请求固定打到同一台机器上。这两种方案都能解决问题,但都引入了额外的运维成本和复杂度。
另一个痛点来自移动端和多端场景。现在的应用基本都同时有Web端、App端、小程序端,Session天然依赖Cookie,而App端对Cookie的处理远不如浏览器顺手。再加上前后端分离之后,前端可能部署在不同域名下,跨域请求要带Cookie就得额外配置Access-Control-Allow-Credentials,各种跨域限制会让联调变得很烦。
我们那会儿还被另一个问题卡着:部分接口要开放给第三方系统调用,这些调用方根本不是浏览器,也没有Cookie的概念,怎么维持认证状态就成了一个绕不开的问题。这才动了换认证方案的心思。
1.2 JWT的核心结构:三段式与单纯数据签名
JWT全称是JSON Web Token,本质上是一段自包含的令牌。说它"自包含",是因为用户的身份信息和过期时间等元数据直接被编码在Token里,服务端拿到Token后不需要查数据库、不需要查缓存,解出来就能确认身份。
一个JWT由三部分组成,用点号分隔:
- Header:声明令牌类型和签名算法,通常是
{"alg":"HS256","typ":"JWT"}。 - Payload:存放实际数据,比如用户ID、用户名、角色、过期时间等。
- Signature:对Header和Payload进行签名的结果,用于防止数据被篡改。
这三部分各自经过Base64Url编码后拼在一起,就形成了一个完整的Token。注意这里用的是Base64Url而不是标准Base64,区别在于字符集里+和/会被替换成-和_,并且省略掉末尾的=,这样Token才能安全地放在URL里而不产生歧义。
签名过程我们当时折腾了一下才彻底搞明白。拿HS256算法来说,签名值是这样算出来的:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)也就是把前两段编码后的字符串用点号连接,再拿密钥做一次HMAC-SHA256计算。服务端解析Token时,会用同样的密钥重新计算签名,拿算出来的结果和Token里携带的签名做比对,不一致就说明Token在传输过程中被改过了。
这段逻辑里有个关键点值得多说一句:Payload里的信息只是Base64Url编码,不是加密。也就是说,任何人拿到Token,都可以直接解码看到里面的数据。所以敏感信息比如密码、身份证号这些,绝对不能放进Payload里。JWT的核心安全边界是"防篡改",不是"防窥探",这个认知从一开始就要建立起来。
1.3 什么时候不要用JWT
这一点我必须放在前面说,因为很多人一听说JWT能解决Session共享问题,就把所有项目都往JWT上迁,结果踩出一堆新的坑。
JWT特别适合的场景是:无状态接口认证、移动端App登录态、前后端完全分离的项目、以及需要开放API给第三方调用的平台。在这些场景里,Token本身携带身份信息,服务端不用维护会话状态,天然适合分布式部署。
但如果你的项目是服务端渲染的传统Web应用,页面都在服务端生成,用户量也不大,Session方案其实更合适。理由很实在:Session可以随时在服务端主动失效,而JWT在签发之后、到期之前,服务端是没有办法主动让它失效的。你没法把已经发出去的Token"叫回来",只能等它自然过期。如果一定要实现"踢人"或"强制下线"功能,就得维护一个黑名单或者主动刷新机制,这些补偿逻辑写下来,代码量和复杂度并不比管理Session低。
另一个不适合的场景是频繁变更权限的权限系统。Session里存角色信息,权限变了随时改;JWT里存了角色,权限一变你就得让用户重新登录,或者在网关层做额外的角色比对,绕一圈又回到了"有状态"的老路上。
我们当时之所以坚持切JWT,是因为系统确实是前后端分离加多端共用,而且接口要给第三方开放,Session方案在这些场景下已经力不从心。如果你不属于这类情况,建议还是再权衡一下,别为了用JWT而用JWT。
2. JJWT依赖配置:版本选型和最容易踩的三个坑
2.1 为什么选JJWT,而不是java-jwt
Java生态里做JWT的库不少,主流的有两个:一个是本文主角JJWT,另一个是auth0的java-jwt。还有个别项目在用Nimbus JOSE + JWA,不过那个偏底层,日常业务开发里用得不多。
我们最终选JJWT,看中的是这几点:
- API设计直观,
Jwts.builder()和Jwts.parserBuilder()这样的链式调用,读代码的时候几乎不需要注释就能看懂在做什么。 - 文档和维护质量相对稳定,不会有"升级一个小版本就换一套用法"的背刺感。
- 支持多种签名算法,从HMAC到RSA都能配,给我们后面做多环境密钥隔离留了余地。
java-jwt其实也不错,API风格同样清爽,但我们在对比时注意到一个实际细节:JJWT在密钥长度校验上做得很明确,密钥太短会直接抛异常告诉我们,而java-jwt在某些版本里对弱密钥的提示不够直观,容易被忽略。对团队里新手友好程度,JJWT更胜一筹。
2.2 Maven依赖坐标与版本差异
如果是Maven项目,JJWT的依赖拆成了三个骨架模块,分别是api、impl、jackson。我们在项目里实际引入的是jjwt-api和jjwt-impl,另外还加了一个jjwt-jackson用来做JSON序列化。依赖坐标长这样:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> </dependency>这里有两个版本雷区,提前说一下,能帮你省不少排查时间。
第一个雷区是版本配套。JJWT的api和impl必须使用完全相同的版本,否则运行时大概率会出现NoClassDefFoundError或者奇怪的ClassCastException。我们有一次升级api到0.12.3却忘了同步impl,结果一调用Jwts.parserBuilder()就炸,报错信息指向的类一看就是从impl里加载的,核对依赖树才发现版本不一致。
第二个雷区是0.12.x和0.11.x的API不兼容。0.12.0之后签名相关的构造方式发生了较大调整,很多网上的老教程代码在新版本下直接编译不过。我们项目里还在用0.11.5,不是因为它最新,而是项目里其他组件对它的兼容性验证过,没必要为了追新而承担回归风险。如果你是新项目,直接上0.12.x就可以,但要注意你是跟着新版本的写法来写,别把老代码原样拷过来。
2.3 配置文件里的密钥长度陷阱
接下来是这次改造中印象最深的一个坑:密钥长度。
我们用HS256算法,它要求密钥至少256位,也就是32字节。在application.yml里如果配了一个很短的字符串,比如my-secret-key,JJWT在生成Token的时候可能不报错,但一旦运行在生产环境或者换了更严格的环境,就可能抛出WeakKeyException,提示密钥强度不足。
这个异常的根因是JJWT 0.10.0以后默认开启了安全增强校验,对HS系算法的密钥长度有强制要求。如果用的是HS256,密钥必须不少于256位;用HS384则要求384位以上。
我们最终的密钥配置方式是生成一个固定长度的随机字符串,然后Base64编码之后放到配置文件里,代码里解码后再传给JJWT。这样做的好处是密钥可以包含任意字符,不会因为配置文件对特殊字符的转义而踩坑。
jwt: secret: bXktc2VjcmV0LWtleS1nZW5lcmF0ZWQtYnktcmFuZG9tLXN0cmluZy0xMjM0NTY3ODkw expire-hours: 24这里再强调一次:密钥必须保存在服务端,绝不能出现在前端代码里,也不能通过接口暴露出去。密钥一旦泄露,别人就能用同样的密钥伪造任意身份的Token,整个认证体系形同虚设。
3. JWT工具类封装:密钥管理、Token生成与解析
3.1 工具类的整体设计思路
依赖配好之后,下一步就是写工具类。我们当时没有把JWT相关代码散落在各个Service里,而是集中封装在一个工具类中,所有涉及Token生成和解析的操作都走这个入口。好处很明显:密钥管理、过期时间、异常处理这些关注点全部收敛在一处,后续要改算法或者换密钥,只需要改一个文件。
工具类的成员变量直接用@Value注入配置项:
@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-hours}") private Long expireHours; }3.2 生成Token的核心逻辑
生成Token的方法我们最终写成这样:
public String generateToken(Long userId, String username, String role) { SecretKey key = getSigningKey(); Date now = new Date(); Date expiry = new Date(now.getTime() + TimeUnit.HOURS.toMillis(expireHours)); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("role", role) .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); }这段代码里有几个设计上的细节可以说说。
Subject字段通常用于存放用户的唯一标识,我们放的是用户ID转成的字符串。自定义信息通过claim方法放进去,这里放了username和role。注意claim里的值最好是基本类型、字符串或者能被正常序列化的对象,别塞一个带循环引用的复杂对象进去,否则序列化阶段就会出问题。
setIssuedAt和setExpiration分别用来设置签发时间和过期时间。过期时间我们允许通过配置调整,默认24小时,方便不同环境用不同的时长。
signWith(key, SignatureAlgorithm.HS256)这行是签名的关键。我们用的SecretKey是通过Keys.hmacShaKeyFor从密钥字符串解析出来的。这里有个重要细节:不要直接拿字符串去调signWith,有时会走偏门签名逻辑,规范的做法是先构建SecretKey对象再传入。
private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); }3.3 解析Token与异常处理
解析Token比生成Token更需要细心,因为你要面对的是各种各样非法的、伪造的、过期的Token。我们的解析方法长这样:
public Claims parseToken(String token) { SecretKey key = getSigningKey(); return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); }这里返回的是Claims对象,它继承了Map<String, Object>,你可以直接通过claims.get("username")取出之前放进去的自定义数据,也可以调用getSubject()、getExpiration()这些专用方法。
这个方法虽然短,但异常处理才是重点。运行时可能抛出的异常主要有这么几类:
ExpiredJwtException:Token已过期,通常意味着用户需要重新登录。UnsupportedJwtException:Token格式不对,或者算法与预期不符。MalformedJwtException:Token被篡改过,三段式结构不完整或编码有问题。SignatureException:签名验证失败,说明Token可能在传输途中被改过。IllegalArgumentException:传入的Token是空字符串或null。
我们在调用层做了统一捕获,返回到前端时给出不同提示。这样做的目的是避免把底层异常信息直接抛给前端,既泄露了实现细节,用户体验也差。
3.4 统一放行与拦截的配合
工具类只是基础,真正让JWT生效的是拦截器。我们在项目中写了一个JwtInterceptor,实现HandlerInterceptor接口,在preHandle里完成Token的提取和校验。
整体流程是这样的:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } try { Claims claims = jwtUtils.parseToken(token); request.setAttribute("userId", claims.getSubject()); request.setAttribute("username", claims.get("username")); return true; } catch (ExpiredJwtException e) { // 返回401和明确提示 } catch (JwtException e) { // 返回401和明确提示 } return false; }这里有个很容易被忽略的点:预检请求OPTIONS。前后端分离部署在不同域名下时,浏览器在发起真正的POST请求前会先发一个OPTIONS预检请求,这个请求是不带Authorization头的,如果拦截器直接拦截,跨域请求就全部失败。所以必须先对OPTIONS放行。
4. 登录流程落地:从用户名密码校验到Token下发
4.1 登录接口的流程设计
工具类封装好之后,就要把它嵌进登录流程了。我们最终的登陆接口流程分四步:
- 接收前端传来的用户名和密码。
- 校验验证码或者其它前置条件。
- 查询用户表,比对密码哈希值。
- 通过校验后调用JwtUtils生成Token返回给前端。
密码比对这里多说两句。用户表里存的绝对不能是明文密码,必须是加盐后的哈希值。我们用的工具是BCrypt,BCryptPasswordEncoder.matches(rawPassword, encodedPassword)来判断密码是否正确。很多项目在改造JWT时顺手把密码存储逻辑也重构一遍,这个习惯是好的,但要注意操作顺序,先确认密码比对逻辑没问题,再去动Token相关代码,否则出了错都分不清是哪一块的问题。
登录接口的伪代码大致是这样的:
public LoginResponse login(LoginRequest request) { User user = userMapper.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } String token = jwtUtils.generateToken(user.getId(), user.getUsername(), user.getRole()); return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole()); }返回的响应我们把Token和相关用户信息一起给前端。之所以把用户基本信息也返回,是为了让前端在本地保存用户状态,避免每次请求都要额外调一个接口去拿用户信息。实际项目中确实有团队这么做,但不是必须,看前端的需求而定。
4.2 Token下发后的前端存储与Header传递
Token生成之后,前端怎么存、怎么带,这个问题看起来简单,实际坑也不少。
市面上最常见的两种方案是localStorage和HttpOnly Cookie。localStorage的实现简单,前端从接口拿到Token后直接存进去,后续请求手动拼到Authorization头里。但localStorage有一个众所周知的隐患:任何在页面里执行的XSS脚本都能读取它,Token一旦被脚本偷走,攻击者就拿到了用户的完整身份。
HttpOnly Cookie这个方案不推荐。因为这个方案能防XSS,但HttpOnly Cookie不能在JavaScript中被读取,前后端分离时需要通过后端Set-Cookie来设置。这个方式要求前端进行CSRF防护。我们当时的项目前端是单页应用,接口调用由前端代码发起,没有用到后端模板渲染,所以我们决定用localStorage + Authorization头的方案,同时配合前端框架做基本的XSS过滤,目前看下来是够用的。
实际请求长这样:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.xxxxx注意Bearer这个前缀,它是一种约定的鉴权Scheme,后端读取时要能正确处理带前缀和不带前缀两种情形。
4.3 刷新Token与退出登录的处理要点
Token过期之后怎么办?这是JWT落地时绕不开的问题。简单粗暴的做法是:过期就要求用户重新登录。但是一个24小时过期的Token,用户可能早上登录后一直用到第二天,频繁要求重新登录显然影响体验。更合理的方案是引入refresh_token双Token机制:access_token短期有效(比如2小时),refresh_token长期有效(比如7天)。access_token过期后,前端用refresh_token去换一个新的access_token,整个过程对用户无感。
双Token机制从实现上看并不算复杂,生成时额外签发一个refresh_token,新增一个刷新接口处理兑换逻辑。但副作用是服务端状态管理的复杂度又回来了:refresh_token通常需要存库或者存在Redis里,因为你得能主动作废它。这一层绕回来之后,其实和Session方案的部分痛点又重叠了。
我们当时的取舍是:先不上双Token,把access_token的过期时间设为一个合理的业务周期,线上跑了两个多月,用户反馈可以接受。如果之后有更长的会话保持需求,再考虑引入刷新机制也不迟。
退出登录这里同样需要想清楚。很多人以为后端调用一个logout接口把Token标记为无效就完事了,但JWT本身是无状态的,服务端没有会话记录可删。在纯JWT方案里,退出登录最常见的手段是前端直接丢弃本地Token,后端不做任何处理。如果业务上要求Token在过期前作废,就得上Redis黑名单:把退出登录的Token存进一个短时效的黑名单里,拦截器每次比对一下。这个方案我们暂时没用,只是整理需求时聊过,因为当前业务对"强制下线"的要求不高。
5. 实际项目中踩过的坑:从时区问题到密钥轮换
5.1 服务器时区不一致导致的过期时间偏差
第一次联调的时候就出现了一个诡异的现象:明明Token设置的过期时间是24小时,但客户端提交Token过来,偶尔会提示已经过期。排查了很久,发现是服务器之间的时区问题。
当时我们有开发机、测试机和正式机的几套环境,有的机器系统时区设置成了UTC,有的设置成了Asia/Shanghai。JWT的setIssuedAt和setExpiration用的都是时间戳,本身不携带时区信息,但一旦服务器在生成和解析之间跨了时区,时间计算上就可能出现偏差。具体来说,生成端在UTC时区下计算过期时间,解析端在Asia/Shanghai时区下读取,相差8小时,如果Token的过期时间设得比较短,偏差就很容易暴露。
解决方式分两步:
- 统一所有服务器的基础时区,在启动脚本里加上
-Duser.timezone=Asia/Shanghai参数,或者在应用配置里设置spring.jackson.time-zone。 - 生成Token时不要依赖本地时间的获取方式,统一用
System.currentTimeMillis()或者是注入的时钟源。
当时我们图省事在代码里直接new Date(),这实际上就是取当前系统时间。多个服务器时间不同步的时候,同样的代码在不同机器上跑出来的行为就不一样,联调起来特别折磨人。
5.2 解析异常的完整排查链路
有一次线上反馈部分用户登录失效频繁,我们拉日志一看,报的是SignatureException。当时的第一反应是密钥被改了,但排查了很久发现并没有人动过配置。后来仔细对了日志和环境,才发现是某台服务器的配置文件没有同步更新,用的还是上一轮的旧密钥。TTL缓存加上服务一直没重启,旧进程持有的还是旧密钥,新旧密钥不一致导致签名验证失败。
那次排查把完整链路重新走了一遍,整理出来这样的排查顺序,分享给你参考:
- 先确定报错类型。
SignatureException看密钥和算法,ExpiredJwtException看时间,MalformedJwtException看Token内容本身。 - 核对密钥。把所有环境的配置拉出来比对,确认加解密用的密钥完全一致。
- 核对Token来源。用在线解析工具解码Token的Header部分,确认签名算法和你预期的一致。如果Token里
alg字段是none,说明压根没签名,这个Token是伪造的。 - 核对代码版本。确认线上运行的Jar包里的工具类是最新版本,别出现灰度发布时新旧逻辑并存的情况。
这个顺序看起来简单,但真到告警满天飞的时候,人容易慌,跳着排查反而更浪费时间。
5.3 密钥管理与轮换策略
JWT密钥属于核心机密,它的管理级别至少应该和其他数据库密码相同。我们在项目里做了几件事,算是把密钥管理的底线兜住了。
第一,不同环境使用不同的密钥。开发环境、测试环境、生产环境的密钥独立配置,互不通用。这样即使开发环境密钥泄露,也不会波及生产环境。
第二,密钥定期轮换。理论上密钥应该有生命周期,但我们并没有做得很复杂,目前的做法是在大版本发版时顺便轮换一次,同时保证新旧密钥有一段重叠窗口,让已签发的Token能平滑过渡。要是做更严格的轮换,就得维护一个密钥版本列表,解析Token时用对应版本的密钥去验签,这个复杂度更高,我们暂时没上。
第三,密钥不入代码仓库。application.yml里的密钥引用的其实是环境变量,仓库里只保留一个占位符。这样可以避免开发者的本地配置被不小心推到远端仓库里。
jwt: secret: ${JWT_SECRET}这几条看着基础,但在实际项目中能坚持做到的并不多。安全上的事,往往是那些看起来最笨、最不高级的措施,最终救了命。
最后说点实际的
整个改造从动手到稳定运行,前后差不多花了一周。真正写代码的时间其实不长,大量时间都耗在依赖选型、密钥设计和各种边角问题上。做完之后最大的感受是:JWT本身不复杂,复杂的是把它放进一个真实的生产环境里,让它和各种已有的机制共存。如果你也在做类似的改造,建议先把密钥和过期时间这两个基础项理清楚,再去碰那些花哨的刷新机制和黑名单逻辑。基础不牢的话,上层设计再精巧,线上出问题的时候照样抓瞎。