【双token登录】 双 Token 登录从概念到实践的完整解读项目仓库Factory_Test_Demo技术栈Spring Boot 4.1 · Java 21 · MyBatis-Plus · MySQL我陆续遇到的问题在做一个多登录方式的认证系统时我陆续遇到这些问题单 Token 和双 Token 的 Access Token时效是不是一回事双 Token 是不是就不会泄露了jti到底是什么Refresh 怎么作废/api/auth/refresh到底什么时候才该调用这篇文章把这些问题串成一条线,讲清楚双 Token 登录的设计思路与实践要点。一、为什么不用「一个 Token 走到底」前后端分离项目里认证方案大致有三条路方案做法优点痛点Session Cookie服务端存会话Cookie 带 SessionId成熟、易撤销跨域麻烦移动端不友好单 JWT Token只发一个 JWT客户端长期保存无状态、实现简单难撤销、难踢人双 TokenAccess 短期 Refresh 长期兼顾体验与安全实现稍复杂单 JWT 的两个典型问题Token 一旦泄露在过期前无法单方面作废无法在服务端主动踢人、撤销登录因为 JWT 是自包含的服务端验签通过就放行除非维护黑名单否则拿一张合法 Token 就能用到过期。二、双 Token 是什么双 Token Access Token Refresh Token各司其职Token生命周期用途携带方式Access Token短15min2h访问受保护 API每次请求Authorization: Bearer ...Refresh Token长730 天仅用于换新 Access只在刷新接口使用┌─────────────┐ │ 客户端 │ └──────┬──────┘ │ Authorization: Bearer accessToken ▼ ┌─────────────┐ access 过期 ┌─────────────┐ │ 业务 API │ ◄────────────────── │ /refresh │ └─────────────┘ 携带 refreshToken └─────────────┘ │ │ │ 200 新 accessToken │ 校验 session ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ 继续访问 │ │ auth_session │ └─────────────┘ └─────────────┘核心思想Access 负责「安全」— 短有效期泄露窗口小Refresh 负责「体验」— 长期有效避免频繁登录Refresh 落库— 服务端可控能撤销三、常见疑问单 Token 12 小时和 Access Token 不是差不多吗是的本质上一样。单 Token 若设12 小时和双 Token 里 Access Token 的12 小时在安全考量上是同一量级都是让「访问凭证」尽快失效。差别不在 Access 能设多长而在过期之后怎么办单 Token12h双 TokenAccess 12h过期后用户必须重新登录用Refresh Token静默换新 Access长期登录只能把 Token 设很长不安全Refresh 可以 730 天Access 仍保持短可以这么记单 Token 一张票既要安全短又要省事长→ 很难两全 双 Token Access 管安全 Refresh 管体验四、双 Token 也会泄露吗会。双 Token 不是「不会泄露」而是「泄露后的后果更可控」。Token泄露后会怎样能否撤销Access Token过期前12h可被冒充访问 API难JWT 无状态靠短过期缩小窗口Refresh Token过期前730 天可不断换新 Access能服务端撤销 session 即可和单 Token 长 JWT 相比单 Token 设 7 天 → 泄露 7 天风险窗口还很难作废双 Token → Access 泄露最多 2 小时Refresh 泄露虽危险但可以登出、改密、踢下线更准确的说法❌ 双 Token 没有泄露问题✅ Access 泄露仍有短期风险靠短过期控制✅ Refresh 泄露更危险但靠服务端撤销控制五、关键概念jti 是什么jti JWT IDJWT 标准声明之一表示「这是哪一张 Token」的唯一标识。声明含义类比sub属于谁userId姓名exp什么时候过期有效期jti哪一张 Token身份证号同一用户可以在手机、电脑各登录一次 →同一个sub不同的jti。在项目中的常见用法Refresh Token 格式为{jti}:{randomSecret}StringjtiUUID.randomUUID().toString();Stringrefreshjti:UUID.randomUUID();登录时写入数据库s.setRefreshTokenJti(jti);// 用来查找 sessions.setRefreshTokenHash(sha256Hex(refresh));// 只存哈希不存明文为什么需要 jti撤销时WHERE refresh_token_jti ?精确定位 session刷新时从 Refresh Token 解析 jti → 查库校验审计时关联「哪次登录、哪台设备」六、Refresh 怎么作废Refresh Token 发给客户端后是字符串没法远程把它「变无效」。作废的本质是让服务端不再承认这条 session。6.1 核心手段revoked_atauth_session表预留了revoked_at字段。刷新时会检查if(s.getRevokedAt()!null)returnnull;if(s.getRefreshExpiresAt().isBefore(LocalDateTime.now()))returnnull;if(!s.getRefreshTokenHash().equals(sha256Hex(refreshToken)))returnnull;只要revoked_at不为空即使客户端还拿着 Refresh Token也会返回2001 invalid or expired refresh token。UPDATEauth_sessionSETrevoked_atNOW()WHERErefresh_token_jti某个jti;6.2 常见作废场景场景做法用户登出单设备当前 session 设revoked_at改密码 / 安全事件该用户所有 session 设revoked_atRefresh Rotation刷新时作废旧 jti签发新 Refresh自然过期refresh_expires_at到期无需主动操作6.3 一个重要细节撤销 Refresh ≠ 立刻让 Access 失效。Access 若在有效期内仍可能被使用直到过期。因此Access 必须短12h敏感操作可要求二次验证可选Redis 黑名单存已撤销的 Accessjti七、/refresh 接口什么时候才该调用这是最容易搞混的点refresh 不是登录接口也不是每次请求都要调。7.1 调用时机时机是否调 refresh登录成功❌ 用 login 返回的双 Tokenaccess 有效正常调业务 API❌ 只带 Access业务 API 返回 401token expired✅打开 Appaccess 过期但 refresh 有效✅定时器发现 access 快过期✅可选主动刷新refresh 返回 2001❌ → 重新 login用户退出❌ → logout7.2 典型流程POST /api/auth/refresh业务接口前端POST /api/auth/refresh业务接口前端请求 Bearer access401 UnauthorizedBody: refreshTokentext/plain新 accessToken refreshToken重试原请求 新 access200 OK7.3 接口细节PostMapping(valuerefresh,consumesMediaType.TEXT_PLAIN_VALUE)publicApiResponseLoginResponserefresh(RequestBodyStringrefreshToken){varrespauthService.refresh(refreshToken.trim());if(respnull)returnApiResponse.error(2001,invalid or expired refresh token);returnApiResponse.ok(resp);}注意Content-Typetext/plainBody 直接是 Refresh 字符串不是 JSON不需要带 Access Token已经过期了7.4 前端防并发刷新多个请求同时 401 时应只发一次 refresh其余请求排队等待letisRefreshingfalse;letpendingQueue[];asyncfunctionrequest(url,options){options.headers{...options.headers,Authorization:Bearer${accessToken}};letrespawaitfetch(url,options);if(resp.status!401)returnresp;if(!isRefreshing){isRefreshingtrue;try{constdataawaitrefresh(refreshToken);accessTokendata.accessToken;pendingQueue.forEach(cbcb(accessToken));pendingQueue[];}catch{redirectToLogin();returnresp;}finally{isRefreshingfalse;}}else{awaitnewPromise(resolvependingQueue.push(resolve));}options.headers.AuthorizationBearer${accessToken};returnfetch(url,options);}八、项目中的完整登录链路Factory_Test_Demo在多种登录方式密码 / 邮箱验证码 / 手机验证码之上统一走双 Token 会话管理。8.1 登录签发双 Tokenauth_sessionLoginHandlerAuthServiceAuthController客户端auth_sessionLoginHandlerAuthServiceAuthController客户端POST /api/auth/loginlogin(req, ip)handler.handle(req)AuthUsercreateSessionAndResponse()写入 jti hash 过期时间accessToken refreshToken expireIn核心代码privateLoginResponsecreateSessionAndResponse(AuthUseruser,booleanrememberMe){Stringaccessaccess:UUID.randomUUID();StringjtiUUID.randomUUID().toString();Stringrefreshjti:UUID.randomUUID();LocalDateTimenowLocalDateTime.now();LocalDateTimeaccessExpnow.plusHours(2);LocalDateTimerefreshExpnow.plusDays(rememberMe?30:7);AuthSessionsnewAuthSession();s.setUser(user);s.setRefreshTokenJti(jti);s.setRefreshTokenHash(sha256Hex(refresh));s.setAccessExpiresAt(accessExp);s.setRefreshExpiresAt(refreshExp);s.setRememberMe(rememberMe);sessionRepo.save(s);LoginResponserespnewLoginResponse();resp.setAccessToken(access);resp.setRefreshToken(refresh);resp.setExpireIn(7200);resp.setUserInfo(newLoginResponse.UserInfo(user.getId(),user.getUsername()));returnresp;}时效策略配置AccessRefreshrememberMefalse2 小时7 天rememberMetrue2 小时30 天8.2 刷新校验 Refresh换新 AccesspublicLoginResponserefresh(StringrefreshToken){String[]partsrefreshToken.split(:,2);if(parts.length!2)returnnull;Stringjtiparts[0];OptionalAuthSessionsosessionRepo.findByRefreshTokenJti(jti);if(so.isEmpty())returnnull;AuthSessionsso.get();if(s.getRevokedAt()!null)returnnull;if(s.getRefreshExpiresAt().isBefore(LocalDateTime.now()))returnnull;if(!s.getRefreshTokenHash().equals(sha256Hex(refreshToken)))returnnull;// 签发新 AccessRefresh 复用Rotation 为后续增强项Stringaccessaccess:UUID.randomUUID();LoginResponserespnewLoginResponse();resp.setAccessToken(access);resp.setRefreshToken(refreshToken);resp.setExpireIn(7200);resp.setUserInfo(newLoginResponse.UserInfo(s.getUser().getId(),s.getUser().getUsername()));returnresp;}8.3 时间轴示意|---- login ----|---- refresh ----|---- refresh ----| ... |---- refresh 失败 ----| 拿双 token access 过期续命 再次续命 refresh 7/30 天到期 ↑ ↑ 每 ~2h 可能触发一次 不是每次请求都触发九、当前实现与完整 JWT 链路的差距项目已完成Refresh 服务端校验闭环Access 目前为演示占位符环节现状目标登录签发双 Token✅—Refresh 校验jti hash 过期 撤销✅—Access 格式access:UUID占位符标准 JWTHS256 签名鉴权拦截器❌ 待实现Bearer Token 校验登出接口❌ 待实现POST /logoutrevoked_atRefresh Rotation❌ 待实现刷新生成新 Refresh作废旧 jti演进路线详见 JWT-Refresh-Token 完整链路。十、安全实践 checklist项要求Refresh 存库只存SHA-256 哈希不存明文Access 有效期短≤ 2hHTTPS生产环境必须Refresh 存储优先 HttpOnly Cookie避免 XSS 窃取撤销能力revoked_at 登出接口防暴力刷新对/refresh做 IP / 用户限流用户禁用登录 / 刷新 / 鉴权时检查auth_user.status十一、面试常问速答1. 为什么用双 Token不用单 TokenAccess 短期无状态校验高效Refresh 长期有状态可撤销兼顾安全与体验。2. 单 Token 12 小时和 Access Token 有什么区别时长思路一样双 Token 多了 Refresh 静默续期不用频繁登录。3. 双 Token 还会泄露吗会。Access 靠短过期控风险Refresh 靠服务端撤销控风险。4. jti 是干什么的Token 的唯一 ID用于查 session、撤销、防重放。5. Refresh 怎么作废数据库auth_session.revoked_at NOW()刷新校验时拒绝。6. refresh 接口什么时候调Access 过期或即将过期时不是登录时也不是每次请求时。7. 撤销 Refresh 后 Access 还能用吗在 Access 过期前仍可能可用所以 Access 必须设短。十二、总结双 Token 登录 Access短、访问 API Refresh长、续期 Session 落库可撤销 记住四句话 1. Access 和单 Token 一样要短12h 2. 双 Token 也会泄露但 Refresh 可撤销 3. jti 是 Refresh 在服务端的主键 4. /refresh 只在 Access 失效时调用不是每次请求都调Factory_Test_Demo已在多登录方式之上搭好了双 Token 会话骨架。下一步接入真实 JWT、鉴权拦截器和登出接口就是一条完整、能写进简历、能讲清设计的认证链路了。