登录认证这层皮:qkl-boot 的 JWT + Redis 会话,签发校验一次说清
导读:登录接口人人会写,但"token 哪来的、怎么验、失效了怎么办"——三句话能讲清的项目不多。qkl-boot 用的是 JWT(jjwt 0.12.6)+ Redis 双保险:JWT 负责无状态校验,Redis 负责可控失效。这套设计把"签发、校验、踢人、续期"四个场景都覆盖了,直接说实现。
先说为什么 JWT 要配 Redis。纯 JWT 的问题很经典:签发出去就管不住了。用户改密码、管理员封号、异地登录踢人,token 还在有效期内就还能用,除非等它自然过期。Redis 里存一份 session 记录,校验时"JWT 合法 + Redis 里有这条会话"双通过,要失效直接删 Redis,立刻生效。代价是多一次 Redis 查询,换取可控性——登录这种低频接口,值。
签发:登录成功,JWT + Redis 一起写
登录接口只做三件事:校验账号密码、生成 JWT、写 Redis 会话。密钥和有效期放配置,别写死在代码里:
# application.ymlqkl:security:jwt-secret:${JWT_SECRET:qkl-boot-default-secret-change-me-in-prod-0123456789}access-token-ttl:7200# token 有效期:秒,2 小时refresh-token-ttl:604800# 刷新有效期:7 天签发逻辑:
@ServicepublicclassAuthService{@Value("${qkl.security.jwt-secret}")privateStringjwtSecret;publicLoginVOlogin(LoginDTOdto){// 1. 校验账号密码(这里用 MyBatis-Plus 查库,密码是 BCrypt 加密)Useruser=userMapper.selectOne(newLambdaQueryWrapper().eq(User::getUsername,dto.getUsername()));if(user==null||!BCrypt.checkpw(dto.getPassword(),user.getPassword())){thrownewBizException("账号或密码错误");}// 2. 签发 JWT:payload 里只放必要信息Stringtoken=Jwts.builder().subject(String.valueOf(user.getId())).claim("role",user.getRole()).issuedAt(newDate()).expiration(newDate(System.currentTimeMillis()+accessTtl*1000)).signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))).compact();// 3. 写 Redis 会话:key 带用户 id,value 是 token 摘要redis.set("login:token:"+user.getId(),token,accessTtl,TimeUnit.SECONDS);returnLoginVO.of(token,user);}}payload 里别塞太多东西,用户角色、id 够用了。密码、手机号这些敏感信息进 JWT 是大忌——JWT 默认不加密,base64 一解码全裸奔。
校验:JWT 验签 + Redis 比对,双保险
Spring Boot 里做认证有两种路子:Filter 硬编码,或者集成 Spring Security 的 OncePerRequestFilter。qkl-boot 用的 Filter 方案,轻量、侵入小:
@ComponentpublicclassJwtAuthFilterextendsOncePerRequestFilter{@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{Stringheader=request.getHeader("Authorization");if(header!=null&&header.startsWith("Bearer ")){Stringtoken=header.substring(7);try{// 1. 验签 + 解析 payloadClaimsclaims=Jwts.parser().verifyWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))).build().parseSignedClaims(token).getPayload();LonguserId=Long.valueOf(claims.getSubject());// 2. Redis 比对:库里没有这条会话 = 已被踢下线Stringcached=redis.get("login:token:"+userId);if(token.equals(cached)){request.setAttribute("userId",userId);request.setAttribute("role",claims.get("role"));}else{thrownewBizException("登录已失效,请重新登录");}}catch(JwtException|BizExceptione){response.setStatus(401);response.getWriter().write("{\"code\":401,\"msg\":\"token 无效或已过期\"}");return;}}chain.doFilter(request,response);}}双保险的意义在这:JWT 验签保证"token 是真的",Redis 比对保证"这条会话还活着"。改密码后把 Redis 删掉,旧 token 验签照样通过,但 Redis 比对失败,直接 401——这就是可控性。
失效与续期:踢人和刷新是两个动作
踢人(强制下线)就是删 Redis,一条命令的事:
// 管理员封号 / 用户改密码 / 异地登录顶号publicvoidkickOut(LonguserId){redis.delete("login:token:"+userId);}续期(刷新)要小心一个细节:刷新不能只重签 JWT,Redis 也得同步更新,否则新旧 token 对不上,前端拿新 token 请求还是 401:
publicStringrefresh(LonguserId,StringoldToken){Stringcached=redis.get("login:token:"+userId);if(!oldToken.equals(cached)){thrownewBizException("刷新凭证无效");}StringnewToken=Jwts.builder().subject(String.valueOf(userId)).issuedAt(newDate()).expiration(newDate(System.currentTimeMillis()+accessTtl*1000)).signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))).compact();// 关键:Redis 里的会话同步换新redis.set("login:token:"+userId,newToken,accessTtl,TimeUnit.SECONDS);returnnewToken;}滑动续期:活跃用户不用频繁重新登录
2 小时有效期对"用一下就走"的场景够用,但招聘、零工这类用户会挂一整天。天天弹"登录已过期"体验太差。qkl-boot 的做法是滑动续期:token 剩余有效期小于阈值(比如 30 分钟)时,下次请求自动换发新 token,Redis 会话同步更新,用户无感知:
// 认证 Filter 里,校验通过后顺手做续期判断if(tokenTtlRemaining<1800){// 剩余不足 30 分钟StringnewToken=authService.refresh(userId,token);response.setHeader("X-New-Token",newToken);// 新 token 放响应头}// 前端 axios 响应拦截器:收到新 token 自动替换并重放service.interceptors.response.use((res)=>{constnewToken=res.headers['x-new-token'];if(newToken){localStorage.setItem('token',newToken);res.config.headers.Authorization='Bearer '+newToken;returnservice.request(res.config);// 用新 token 重发原请求}returnres;});注意滑动续期只在"剩余时间不足"时才换——每次都换 token,Redis 写操作翻好几倍,而且前端频繁替换 token 容易踩到并发请求用旧 token 的竞态。只在阈值边缘续一次,成本和体验都平衡。
踩坑实录:改完密码,老 token 还能用 2 小时
现象:用户在前台改了密码,立刻用旧密码的 token 调接口,居然还能正常访问。安全同事当场质疑:改密码后旧凭证应该立即失效。
排查过程:先看认证 Filter,验签 + Redis 比对都执行了。再看 Redis key——发现login:token:{userId}这条会话根本没有在改密码时被删。翻修改密码的 service 代码,只做了UPDATE user SET password=...,压根没调踢人逻辑。JWT 2 小时有效期内,Redis 会话一直在,老 token 自然畅通无阻。
定位思路:问题不在认证链路,在业务操作没有联动会话失效。改密码、封号、强制退出,这些动作都应该统一走"删除会话"逻辑,但当时的代码里只有登录会写会话,没有任何地方删会话。
最终解决:抽象一个会话管理服务,改密码、封号、退出登录统一调SessionManager.invalidate(userId):
@ServicepublicclassSessionManager{publicvoidinvalidate(LonguserId){redis.delete("login:token:"+userId);// 顺便把刷新 token 的 key 也删了,双保险redis.delete("refresh:token:"+userId);}}// 修改密码时userMapper.updatePassword(userId,BCrypt.hashpw(newPwd,BCrypt.gensalt()));sessionManager.invalidate(userId);// 旧 token 立刻失效改完复测:改密码 → 旧 token 请求 → 401,新登录拿新 token 正常。凡是"凭证不该再用"的业务动作,都必须联动失效会话,这个约定要写进团队规范。
可以直接抄的结论
- JWT payload 只放 id + role,敏感信息不进 token;
- 认证 Filter:验签(JWT)+ Redis 会话比对(可控失效)双保险;
- 踢人 = 删 Redis 会话,一条命令生效,不用等 token 过期;
- 刷新 token 必须 Redis 同步换新,否则新旧不一致会 401;
- 改密码、封号、退出登录统一走
SessionManager.invalidate,别散落在各业务代码里; - 密钥走环境变量注入,默认值只用于本地开发,上生产必须改。
JWT 给了你无状态,Redis 给了你可控。两个一拼,登录认证这层皮才算完整——既快,又管得住。
项目源码:https://gitee.com/gzqkl/qkl-boot