☰
Spring Boot登录令牌实战:从Session到JWT的无状态认证方案
2026/10/20 14:16:29 网站建设 项目流程

从单体应用迁到微服务的时候,我第一次认真反思登录状态到底该怎么存。以前一台服务器撑起所有请求,Session 放内存里怎么玩都行,可一旦服务拆开、实例多起来,登录令牌就成了绕不开的话题。这篇文章就围绕我实际做过的 Spring Boot 项目,把登录令牌这套技术的选型逻辑、JWT 工作原理、完整落地代码和排查经验一次说清楚,适合刚接触令牌认证的后端开发,也适合正在从 Session 往 Token 方案迁移的团队参考。

1. 登录状态管理的底层逻辑:为什么说令牌不是简单替代方案

1.1 从 Session 到 Token 的迁移诱因

我在一个电商类模拟项目里经历过典型的 Session 之痛。早期的单体架构下,用户登录后把用户 ID 写进 Session,再用 Cookie 保存会话 ID,这套组合拳在单机部署下运行得很丝滑。但后来为了扛住大促流量,服务做了横向扩容,多个实例背后是负载均衡,Session 默认存在单个实例内存里,用户第一次请求打到 A 机器,下一次请求被转发到 B 机器,会话就丢了。

为了处理这个问题,团队第一反应是引入 Session 共享机制,把 Session 数据挪到 Redis 里。这个思路能解决问题,但隐患也随之而来。每次请求都要拿会话 ID 回 Redis 查一次数据,网络开销真实存在;更重要的是,Session 里的数据本质上是服务端内存的延伸,如果业务方不小心往里面塞了太多对象,Redis 内存会快速增长,排查起来也很费劲。到了多端登录、小程序和 App 并存的时候,会话管理的复杂度进一步上升,前端要处理 Cookie 跨域,后端要维护会话同步,整体体验非常割裂。

正是在这个背景下,我开始认真考虑切换成 Token 方案,也就是登录成功后签发一段令牌,后续请求都带着这段令牌来证明身份。这个思路的核心变化是:服务端不再保存会话数据,身份信息经过签名后直接交给客户端保管,服务端只负责验证签名是否有效。

1.2 无状态认证带来的架构红利

Token 方案天然是适合分布式环境的。因为服务端不保存状态,每个实例都能独立验证令牌合法性,不需要像 Session 那样做共享存储,也不需要担心某个实例宕机会导致会话丢失。这在大规模集群部署、容器化弹性伸缩的场景下非常舒服,扩容时不需要额外处理会话数据迁移,缩容时也不会有残留的会话孤儿数据。

无状态还能降低后端存储压力。设想一个每天几千万请求的接口,如果每个请求都用会话 ID 去查 Redis,Redis 的 QPS 会非常惊人。换成 Token 方案后,主链路验证只需要本地解码和签名校验,对中间件的依赖明显减少。我自己在某个模拟项目中做过粗略统计,切换后 Redis 的每秒请求数下降了将近三成,这部分释放出来的资源可以用到更核心的业务缓存上。

不过需要冷静看待的是,无状态并不等于无成本。Token 方案的验证过程需要解密、验签、解析,这些操作要吃一些 CPU;Token 一旦签发,在有效期内很难做到主动吊销,除非引入额外的黑名单机制。所以选型时不能一刀切,而是要结合业务实际判断。

1.3 JWT 在令牌方案中的角色

提到登录令牌,市面上绕不开的两个方向是 OAuth2.0 体系下的各类令牌,以及 JWT 这种紧凑的令牌规范。大多数业务系统里说的“登录令牌”,落到实处其实都是 JWT。JWT 的全称是 JSON Web Token,它把身份信息编码成一段紧凑的字符串,自带签名,既能承载用户基本信息,又能防止内容被篡改。

我之所以在项目里选 JWT,核心原因是它的自包含特性。用户 ID、昵称、角色、过期时间这些数据都能放进令牌里,后端接口拿到令牌解出来就能用,不需要为了拿用户信息再查一次数据库或缓存。这个特性跟业务系统的诉求非常匹配,也让接口层的权限判断变得轻量。后面我会详细拆解 JWT 的结构,这里先记住一个结论:JWT 不是唯一的 Token 实现方式,但它是最适合大多数 Spring Boot 业务系统的方案。

2. 核心机制拆解:令牌组成、签名验证与生命周期设计

2.1 Header、Payload 与 Signature 的分工

JWT 长什么样,用一个实际例子来看最直观。一段典型令牌是这样的:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMDAxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDAwMDAwMDB9.5Zx7s4YoBqKp2eWvLhY2nzjWQ9nN8KdE

这段字符串用两个点拆成三段。第一段是 Header,里面记录签名算法和令牌类型,通常是{"alg":"HS256","typ":"JWT"}经过 Base64Url 编码后的结果。第二段是 Payload,也就是载荷部分,放的是业务需要的那些声明,比如用户 ID、角色、过期时间等。第三段是 Signature,用 Header 里的算法对前两段做签名计算,得到的结果附加在最后。

需要注意一个细节:Payload 里的内容只是 Base64Url 编码,不是加密。任何拿到令牌的人都可以解码看到里面的数据。所以敏感的字段不能往 Payload 里放,比如密码、身份证号、手机号这类信息坚决不能进令牌。令牌的设计目标是在“可验证”和“可信任”之间取得平衡,而不是做数据加密容器。

2.2 验签流程:为什么说服务端只信签名

签名是 JWT 安全性的基石。服务端签发 JWT 时,会把 Header 和 Payload 拼成一个待签名串,然后用约定的密钥进行 HMAC 计算,生成 Signature。客户端后续请求携带令牌时,服务端用同样的密钥重新计算签名,再跟令牌里的签名比对,如果一致,说明内容没有被篡改;如果不一致,直接判定为非法令牌。

用生活化的类比来解释:签名相当于盖了一个防伪章。Payload 里的用户 ID 是写在纸上的内容,章的印记则是防伪标记。别人可以复印这张纸,但如果没有真正的章,永远无法伪造出一样的印记。服务端只要验章,就能判断纸上的内容是不是官方发的。这就是我在代码里严格校验签名的原因,哪怕解析步骤繁琐一点,也绝对不能跳过验签直接信任 Payload 里的内容。

我在项目里处理过一个案例,有同事图省事,在过滤器里只做了 Base64Url 解码,拿到 Payload 里的 userId 就直接当成当前用户。看起来接口能跑通,但隐患非常大,因为客户端只要把 userId 改成别人的,后端根本无法识别。这个坑属于典型的“假安全”:表面用了 JWT,实际绕过了验签,等于把身份伪造的大门敞开了。

2.3 有效期、双令牌与主动失效策略

JWT 签发之后就是一段独立的数据,服务端不像 Session 那样能直接删掉它。所以过期时间的设计非常关键。我常用的做法是双令牌机制:短期的 Access Token 用于日常接口访问,有效期设在 30 分钟到 2 小时之间,具体数值根据业务敏感度调整;长期的 Refresh Token 用来在 Access Token 过期后换取新的 Access Token,有效期通常设置在 7 天到 30 天。

双令牌设计的原因很简单。如果 Access Token 有效期太长,万一令牌泄漏,攻击者获得的“合法身份”窗口期就很大。如果太短,用户过一会儿就要重新登录,体验又会变得很差。引入 Refresh Token 后,Access Token 的失效时间可以做到比较短,而用户感知不到频繁重新登录,只在后台静默刷新。

主动失效的问题绕不开。遇到用户修改密码、退出登录、账号封禁时,旧令牌仍然可能一直有效到过期。处理方案有三种。第一种最简单粗暴,缩短令牌有效期,把风险窗口控制在可接受范围内。第二种是引入令牌版本号,把版本号存在 Redis 里,每次修改密码或封禁账号时递增版本号,验签时对比版本,不一致就拒绝。第三种是维护一个黑名单,把需要主动失效的令牌 jti 标识放进 Redis,设置时效跟令牌剩余有效期一致。这三种方案各有适用场景,我在实战项目中通常把第二种和第三种结合使用,既控制 Redis 存储量,又能做到比较及时的吊销。

3. 代码实战:Spring Boot 里从零搭一套登录令牌方案

3.1 依赖准备与整体结构设计

先交代一下项目基础。我用的是 Spring Boot 2.7.x 版本,JDK 1.8,鉴权这块选了 Java JWT 库,就是io.jsonwebtoken下的jjwt系列。这个库稳定、API 简洁,对 Spring Boot 的整合也友好。实际工程里我用 Maven 管理依赖,下面是关键依赖:

<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> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

项目结构上,我习惯把令牌相关的代码收敛到一个独立的包下面,不散落在业务代码里。核心类包括一个 JwtTokenProvider 负责令牌生成与解析,一个 JwtAuthenticationFilter 负责把请求头的令牌解析成用户信息,一个 SecurityConfig 做安全配置,以及一个 UserContext 工具类,用来在业务代码里通过 ThreadLocal 拿当前登录用户。

3.2 令牌生成与解析工具类的实现

JwtTokenProvider 是核心类,负责两件事:根据用户信息生成令牌,把令牌解析回用户信息。这块实现起来不难,但有几个细节值得注意。我用 HS256 对称签名算法,密钥配置在 application.yml 里,长度至少 32 字节,生产环境要用环境变量注入,不能明文写死在代码仓库里。

先看生成令牌的核心代码:

@Component public class JwtTokenProvider { @Value("${jwt.secret}") private String secret; @Value("${jwt.access-token-expire-seconds}") private long accessTokenExpireSeconds; @Value("${jwt.refresh-token-expire-seconds}") private long refreshTokenExpireSeconds; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateAccessToken(Long userId, String role) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + accessTokenExpireSeconds * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } }

这段代码里我用setSubject存放用户 ID,用claim存放角色信息,签发时间和过期时间都通过 JWT 标准字段设置。signWith指定了密钥和签名算法。注意.compact()返回的就是前面看到的三段式令牌字符串。

解析并验证令牌的逻辑更要注意异常处理。日常开发中,令牌过期、签名不匹配、格式非法都会抛出不同类型的异常,必须分类捕获,给上层返回明确的提示信息:

public Claims parseToken(String token) { try { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new BusinessException("登录状态已过期,请重新登录"); } catch (JwtException | IllegalArgumentException e) { throw new BusinessException("非法令牌,请重新登录"); } }

这套解析逻辑有一个隐藏的好处:在验证签名的同时,JWT 库会自动检查过期时间,省得自己手动判断。这也是我坚持用成熟库而不是自己拼字符串的原因,自己写签名和验签很容易漏掉边界条件。

3.3 登录接口与请求拦截的落地

登录接口负责校验用户名密码,校验通过后签发令牌。生产环境里密码必须用 BCrypt 这类不可逆哈希存储,绝对不允许明文存储。登录接口的简化实现如下:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserAccountService userAccountService; @Autowired private JwtTokenProvider jwtTokenProvider; @PostMapping("/login") public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) { UserAccount user = userAccountService.authenticate(request.getUsername(), request.getPassword()); String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getRole()); String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId()); return ResponseEntity.ok(new LoginResponse(accessToken, refreshToken, accessTokenExpireSeconds)); } }

刷新令牌的接口不能只靠一个 Refresh Token 字符串就放行。我的做法是把 Refresh Token 存一份在 Redis 里,key 是用户 ID 加随机标识,value 是令牌本身,换取新令牌时先比对 Redis 里的值是否一致,然后再签发新的 Access Token。这样能在用户主动退出时把 Redis 里的 Refresh Token 删掉,让老的 Refresh Token 彻底失效。

请求拦截是用 Spring Security 的过滤器链实现的。我写了一个 JwtAuthenticationFilter,继承OncePerRequestFilter,在请求进入 Controller 前解析令牌、设置用户上下文。核心代码如下:

public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtTokenProvider jwtTokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && jwtTokenProvider.validateToken(token)) { Claims claims = jwtTokenProvider.parseToken(token); Long userId = Long.valueOf(claims.getSubject()); String role = claims.get("role", String.class); UserContext.set(new CurrentUser(userId, role)); } try { filterChain.doFilter(request, response); } finally { UserContext.clear(); } } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }

这段代码里有个容易忽略的细节:ThreadLocal 里的用户信息必须在过滤器返回前清理。如果不清理,在高并发线程池复用的情况下,下一次请求可能会读到上一次遗留的用户信息,造成串号,这是非常严重的身份错乱问题。

3.4 安全配置与权限控制的取舍

Spring Security 的配置要回答一个问题:哪些接口匿名可访问,哪些必须登录,哪些还需要角色权限。我的配置大致是用 SecurityFilterChain 的方式来做:

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/refresh").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

这里把 Session 创建策略设成了 STATELESS,因为令牌方案本身就是无状态的,没必要再去创建 HttpSession。antMatchers的配置顺序也需要注意,Spring Security 按从上到下的顺序匹配,所以放行的接口要写在前面,受保护接口写在后面,否则会先命中anyRequest()导致放行失效。

业务方法里的权限控制,我一般在 Controller 层用注解做粗粒度拦截,在 Service 层再做细粒度的数据权限判断。比如查看订单详情,既要判断用户是否登录,又要判断订单归属人是不是当前用户,这层校验不能省,只依赖路径参数里的订单号去做查询很容易出现越权漏洞。

4. 上线之后踩过的坑:令牌领域的排查实录与安全加固

4.1 令牌解析失败的常见原因与定位思路

令牌相关的问题有个特点:表面看是同一类报错,实际原因千差万别。我这里把实战里最常见的三类情况整理一下。

第一类是io.jsonwebtoken.security.SignatureException,也就是签名不匹配。出现这个异常的原因通常是密钥不一致。我经历过一个场景:某服务用配置中心的密钥启动,另一个服务用本地配置文件里的旧密钥启动,两边的密钥不一致,导致 A 服务签发的令牌在 B 服务里验签失败。这个问题的排查优先级最高,因为密钥不一致是灾难性的,会让整个认证链路瘫痪。

第二类是ExpiredJwtException,令牌过期。这个问题看起来很简单,但实际会引出一个新坑:如果前端没有实现静默刷新机制,用户 Access Token 过期后重新登录,体验会断档。所以我后来在项目里做了响应拦截,遇到 401 且错误码是令牌过期时,自动用 Refresh Token 请求新令牌,然后重放原始请求。这个机制上线后,用户感受到的“突然掉线”问题基本消失了。

第三类是令牌格式错误。前端传递令牌时多加了一个空格、在拼接 Authorization 头时漏掉Bearer前缀、或者令牌在传输过程中被截断,都会导致解析失败。这类问题排查相对容易,把请求头的真实内容打出来看一遍就清楚了。我习惯在过滤器里加一个开发环境的日志输出,把解析失败的 token 前缀记录一下,方便快速定位。

4.2 令牌泄漏风险与防盗用措施

令牌一旦泄漏,相当于账号被“借用”。所以令牌方案的第二个工作重心就是减少泄漏风险、缩短泄漏后的危害窗口。传输层面必须全程走 HTTPS,这是底线。日志层面要做脱敏,不能把 Authorization 头的完整内容打到日志里,否则令牌会跟着日志一起散落在各处。

我还在项目里引入了一个简单的设备指纹机制。签发 Access Token 时,把客户端上报的设备唯一标识塞进 Payload,比如经过哈希处理的设备 ID、操作系统版本等信息。服务端在过滤器中比对当前请求的设备标识与令牌里的记录,不一致就拒绝。这个机制能有效拦截“令牌被复制到另一台机器使用”的攻击场景。当然,这个方案会增加一些开发复杂度,适合安全要求较高的业务系统。

另一个防盗用手段是“单点登录强制下线”。引入令牌版本号之后,每次用户重新登录都让版本号加一,旧令牌校验时会发现版本号落后,直接被拒。这个做法的副作用是不能多端同时登录,适合内部管理系统这类只需要单端登录的场景。如果是 C 端业务需要多端在线,那就得换成“多版本并存”的策略,不能用这种一刀切的办法。

4.3 过期时间与刷新策略的调整经验

我在项目初期把 Access Token 过期时间设置为 24 小时,理由是“方便用户少登录几次”。结果上线后发现风险窗口太长,用户在一次弱网环境下抓包看到的令牌能用一个整天,这个隐患让我果断把过期时间缩短到 30 分钟,同时把 Refresh Token 的过期时间设为 14 天,配合前端静默刷新,用户无感续期。

刷新策略还要应对并发问题。多个请求同时到达时,各自发现 Access Token 过期,会同时用 Refresh Token 去刷新,导致短时间内的重复签发。我通过在 Redis 里设置一个短期的互斥锁来解决,key 是refresh:lock:{userId},拿到锁的请求负责刷新令牌,其他请求短暂等待后直接使用新令牌。这个细节一开始没做,后来是看到刷新接口的调用量突然暴涨才发现的并发问题。

4.4 日志、监控与应急响应的配套建设

令牌认证链路一旦出问题,影响的是所有需要登录的接口,所以可观测性必须跟得上。我在项目里对三类关键指标做了监控:登录接口的成功率和耗时、令牌签发量、令牌解析失败率。解析失败率突然升高往往意味着两类问题:一类是攻击者在尝试伪造令牌,另一类是系统升级导致的密钥轮换没有完成。

密钥轮换本身也要设计好流程。直接修改密钥会让所有已签发的令牌瞬间失灵,所以我会采用双密钥校验的过渡方案:配置中心同时保留新旧两个密钥,解析时先尝试新密钥,失败再尝试旧密钥,等上一批令牌全部过期后再移除旧密钥。这个流程在灰度发布场景下很实用,避免了“改个密钥全员掉线”的惨剧。

应急响应方面,我准备了一个后门接口,仅在内网通过特殊通道暴露,用来在令牌服务异常时快速清理指定用户的令牌状态。这个接口平时不开启,只有出问题时由运维手动激活,算是给自己的留一条安全逃生通道。

最后再分享一点个人体会

做登录令牌技术,最容易踩的误区是“写完发现问题,先放一放”。我在项目里吃过亏,把令牌工具类写完,过滤器接好,接口测通,就以为万事大吉。直到一次压测暴露出 ThreadLocal 没清理导致用户串号的问题,我才意识到,令牌方案表面上代码量不大,真正的工作量在边界情况和异常链路上。

后来每做一个新项目,我都会用一套自检清单来复盘:密钥是否安全托管、Payload 是否放了敏感字段、Access Token 过期时间是否合理、Refresh Token 是否支持主动失效、过滤器是否清理用户上下文、前端是否处理了静默刷新、日志是否脱敏、监控是否覆盖认证链路。这几条逐一过完,令牌方案才算真正落地。

如果你正在迁移或者刚起步,我的建议是先不要追求复杂的 OAuth2 授权码流程,大多数业务系统最需要的只是一套清晰的 JWT 登录令牌。先把令牌签发、校验、刷新、退出这条主线跑通,再逐步加固。踩过一轮坑之后你会发现,登录令牌不是把 Session 换一种存法,而是把整个认证流程从“服务端记住你是谁”变成“你出示的凭证是否可信”,这个思维转变,才是这套方案真正的价值所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询