写在前面:做过前后端分离项目的同学应该都遇到过这个问题——Token过期了怎么办?是让用户重新登录?还是静默刷新?重新登录用户体验差,静默刷新又担心安全性。今天聊聊双令牌模式,这是Sa-Token、Spring Security OAuth2等框架都在用的经典方案。
文章目录
- 一、为什么需要双令牌?
- 1.1 单Token方案的痛点
- 1.2 双Token如何解决?
- 二、双令牌的核心实现
- 2.1 登录时返回双Token
- 2.2 Token刷新接口
- 2.3 Token失效(踢人/改密)
- 三、前端如何配合?
- 3.1 Token存储策略
- 3.2 Token过期自动刷新(拦截器)
- 四、预判问题与解答
- Q1:Refresh Token泄露了怎么办?
- Q2:双Token比单Token多了几次请求,性能会不会有问题?
- Q3:JWT还是Session?双Token适合哪种?
- Q4:Access Token要不要存Redis?
- Q5:刷新时要不要返回新的Refresh Token?
- 五、与Sa-Token的对比
- 六、面试高频考点
- 面试官问:请介绍一下双令牌模式的设计思路
- 面试官问:Refresh Token泄露怎么办?
- 面试官问:双Token和单Token各有什么优缺点?
- 面试官问:Access Token和Refresh Token存储位置有什么讲究?
- 七、总结
- 参考资料
一、为什么需要双令牌?
1.1 单Token方案的痛点
踩坑提醒:很多新手项目只用一个Token,有效期设7天。看起来用户体验好,但一旦Token泄露,攻击者有7天时间冒用用户身份!
单Token方案的两个极端:
| 有效期 | 问题 |
|---|---|
| 短(30分钟) | 用户频繁登录,体验差 |
| 长(7天) | Token泄露风险大,无法主动失效 |
实际场景:你做的App,用户登录后7天内免登录。某天用户手机丢了,捡到手机的人打开App,直接就是登录状态。用户改密码?没用,Token还在有效期内。
1.2 双Token如何解决?
核心思路:用两个Token分工
- Access Token:有效期短(30分钟),用于接口访问
- Refresh Token:有效期长(7天),用于刷新Access Token
用户登录 ↓ 返回 Access Token(30分钟) + Refresh Token(7天) ↓ 前端用 Access Token 调接口 ↓ Access Token 过期(30分钟后) ↓ 前端用 Refresh Token 换新的 Access Token ↓ 用户无感知,继续使用经验之谈:Access Token泄露只影响30分钟,Refresh Token泄露可以主动失效(存数据库,删了就失效)。这就是"风险隔离"的设计思想。
二、双令牌的核心实现
2.1 登录时返回双Token
publicclassAuthController{@PostMapping("/login")publicResultlogin(@RequestBodyLoginDTOdto){// 1. 校验用户名密码Useruser=userService.checkLogin(dto.getUsername(),dto.getPassword());// 2. 生成 Access Token(短有效期)StringaccessToken=JwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L// 30分钟);// 3. 生成 Refresh Token(长有效期)StringrefreshToken=JwtUtil.createToken(user.getId(),"refresh",7*24*60*60*1000L// 7天);// 4. Refresh Token 存数据库(支持主动失效)tokenService.saveRefreshToken(user.getId(),refreshToken,7*24*60*60);// 5. 返回双TokenreturnResult.success(Map.of("accessToken",accessToken,"refreshToken",refreshToken,"accessExpire",30*60,"refreshExpire",7*24*60*60));}}关键点说明:
- Access Token不需要存数据库,纯内存验证(JWT自包含用户信息)
- Refresh Token必须存数据库,否则无法主动失效
- 两个Token用不同的标识区分(payload里加个type字段)
2.2 Token刷新接口
@PostMapping("/refresh")publicResultrefresh(@RequestBodyRefreshDTOdto){StringrefreshToken=dto.getRefreshToken();// 1. 校验 Refresh Token 格式和签名if(!JwtUtil.verify(refreshToken)){thrownewBizException("Refresh Token无效");}// 2. 校验 Refresh Token 是否在数据库中(可能已被删除)LonguserId=JwtUtil.getUserId(refreshToken);if(!tokenService.existsRefreshToken(userId,refreshToken)){thrownewBizException("Refresh Token已失效,请重新登录");}// 3. 校验 Token 类型(防止用 Access Token 来刷新)Stringtype=JwtUtil.getType(refreshToken);if(!"refresh".equals(type)){thrownewBizException("Token类型错误");}// 4. 生成新的 Access TokenUseruser=userService.getById(userId);StringnewAccessToken=JwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L);// 5. Refresh Token 轮转(可选:每次刷新生成新的Refresh Token)StringnewRefreshToken=JwtUtil.createToken(userId,"refresh",7*24*60*60*1000L);tokenService.updateRefreshToken(userId,refreshToken,newRefreshToken);returnResult.success(Map.of("accessToken",newAccessToken,"refreshToken",newRefreshToken,"accessExpire",30*60));}踩坑提醒:一定要校验Token类型!否则用户拿Access Token也能刷新,那双Token的意义就没了。
2.3 Token失效(踢人/改密)
@PostMapping("/logout")publicResultlogout(){LonguserId=SecurityUtil.getUserId();// 删除数据库中的 Refresh TokentokenService.deleteRefreshToken(userId);// Access Token 不需要删,等它自然过期就行returnResult.success("退出成功");}@PostMapping("/changePassword")publicResultchangePassword(@RequestBodyChangePwdDTOdto){LonguserId=SecurityUtil.getUserId();userService.changePassword(userId,dto.getNewPassword());// 改密后踢掉所有设备tokenService.deleteAllRefreshToken(userId);returnResult.success("密码修改成功,请重新登录");}关键点:改密码后必须删除所有Refresh Token,否则用户改了密码,旧Token还能继续刷新。
三、前端如何配合?
3.1 Token存储策略
| 存储位置 | Access Token | Refresh Token |
|---|---|---|
| localStorage | ❌ 不安全 | ❌ 不安全 |
| sessionStorage | ✅ 相对安全 | ❌ 关闭浏览器就没了 |
| 内存变量 | ✅ 最安全 | ✅ 最安全 |
| Cookie(HttpOnly) | ✅ 防XSS | ✅ 防XSS |
经验之谈:我推荐的做法是Access Token放内存(Vue的data/React的state),Refresh Token放Cookie(HttpOnly + Secure)。这样XSS拿不到Token,CSRF也难以利用(因为CSRF拿不到Cookie内容)。
3.2 Token过期自动刷新(拦截器)
// axios 拦截器示例axios.interceptors.response.use(response=>response,asyncerror=>{const{response,config}=error;// Access Token 过期(401)if(response?.status===401&&!config._retry){config._retry=true;// 防止无限重试try{// 用 Refresh Token 换新的 Access Tokenconst{data}=awaitaxios.post('/auth/refresh',{refreshToken:getRefreshToken()});// 更新本地 TokensetAccessToken(data.accessToken);if(data.refreshToken){setRefreshToken(data.refreshToken);}// 重试原请求config.headers.Authorization=`Bearer${data.accessToken}`;returnaxios.request(config);}catch(e){// Refresh Token 也失效了,跳转登录页router.push('/login');returnPromise.reject(e);}}returnPromise.reject(error);});四、预判问题与解答
Q1:Refresh Token泄露了怎么办?
问题场景:黑客拿到了用户的Refresh Token,能不能一直刷新Access Token?
解答:
- Refresh Token轮转:每次刷新生成新的Refresh Token,旧的立即失效。这样黑客拿到的Token可能已经被轮转掉了。
- 设备绑定:Refresh Token绑定设备指纹(IP+UA+DeviceId),刷新时校验设备信息。
- 单设备登录:一个用户只允许一个Refresh Token,新登录踢掉旧Token。
- 异常检测:刷新频率异常(如1分钟刷新10次)、异地刷新,触发告警或自动失效。
// 设备绑定示例@PostMapping("/refresh")publicResultrefresh(@RequestBodyRefreshDTOdto,HttpServletRequestrequest){StringdeviceFingerprint=generateFingerprint(request);if(!tokenService.checkDevice(userId,refreshToken,deviceFingerprint)){tokenService.deleteRefreshToken(userId);// 异常,踢掉thrownewBizException("设备异常,请重新登录");}// ...}Q2:双Token比单Token多了几次请求,性能会不会有问题?
解答:
- 刷新请求只在Access Token过期时发生(30分钟一次)
- 刷新请求很轻量(JWT验证 + 数据库查询)
- 相比用户重新登录输入密码,刷新请求几乎无感知
性能对比:
| 方案 | 7天内的请求次数 | 用户感知 |
|---|---|---|
| 单Token(7天有效) | 0次额外请求 | 无感知,但不安全 |
| 单Token(30分钟有效) | 用户手动登录约16次 | 每次都要输入密码 |
| 双Token | 自动刷新约16次 | 无感知 |
Q3:JWT还是Session?双Token适合哪种?
解答:双Token模式更适合JWT方案。
| 方案 | Access Token | Refresh Token |
|---|---|---|
| JWT | 存JWT(自包含信息) | 存数据库(支持失效) |
| Session | 存SessionId | 存数据库 |
Session方案本身就可以在服务端失效,不需要双Token。双Token的价值主要体现在JWT的无状态特性上。
Q4:Access Token要不要存Redis?
解答:不建议存Redis,原因:
- JWT自包含用户信息,存Redis就失去了无状态的优势
- 每次请求都要查Redis,性能下降
- Access Token有效期短(30分钟),泄露风险可控
例外情况:
- 需要实时踢人(删Redis立即生效)
- 需要控制并发登录数
Q5:刷新时要不要返回新的Refresh Token?
解答:建议返回,这叫"Token轮转"。
| 策略 | 优点 | 缺点 |
|---|---|---|
| 不轮转 | 实现简单 | Refresh Token泄露后7天有效 |
| 轮转 | 泄露风险低(旧Token立即失效) | 实现稍复杂 |
经验之谈:我建议轮转。实现复杂度增加不多,安全性提升明显。
五、与Sa-Token的对比
Sa-Token框架内置了双Token模式,看看它是怎么做的:
// Sa-Token 配置@ConfigurationpublicclassSaTokenConfig{@BeanpublicStpInterfacestpInterface(){returnnewStpInterfaceImpl();}}// 登录@PostMapping("/login")publicResultlogin(@RequestBodyLoginDTOdto){// Sa-Token 一行代码搞定登录StpUtil.login(userId);// 获取 TokenStringtokenValue=StpUtil.getTokenValue();returnResult.success(Map.of("token",tokenValue));}// Sa-Token 自动支持 Token 续期// 配置文件设置:// token-style=uuid// token-active-timeout=1800 (30分钟活跃超时)// token-timeout=604800 (7天绝对超时)Sa-Token的"活跃超时"和"绝对超时"本质上就是双Token的思想:
- 活跃超时 = Access Token有效期
- 绝对超时 = Refresh Token有效期
六、面试高频考点
面试官问:请介绍一下双令牌模式的设计思路
参考答案:
双令牌模式是解决"安全性"和"用户体验"矛盾的经典方案。
核心思路是用两个Token分工:
- Access Token负责接口访问,有效期短(30分钟),泄露风险可控
- Refresh Token负责刷新Access Token,有效期长(7天),存数据库支持主动失效
工作流程:
- 登录时返回双Token
- 前端用Access Token调接口
- Access Token过期后,前端用Refresh Token静默刷新
- 用户无感知,除非Refresh Token也失效(才需要重新登录)
关键设计点:
- Refresh Token存数据库,支持踢人、改密失效
- Token类型校验,防止用Access Token刷新
- Token轮转,每次刷新生成新Refresh Token
面试官问:Refresh Token泄露怎么办?
参考答案:
多层防护:
- Token轮转:每次刷新生成新Token,旧Token立即失效
- 设备绑定:绑定IP+UA+设备指纹,异常设备拒绝刷新
- 单设备登录:新登录踢掉旧Token
- 异常检测:刷新频率异常、异地刷新触发告警
- 有效期控制:Refresh Token有效期不要太长(建议7天以内)
面试官问:双Token和单Token各有什么优缺点?
参考答案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 单Token(短) | 安全性好 | 用户频繁登录,体验差 |
| 单Token(长) | 用户体验好 | 泄露风险大,无法主动失效 |
| 双Token | 兼顾安全和体验 | 实现稍复杂,多几次刷新请求 |
实际项目中,前后端分离、移动端App推荐双Token;传统Web应用用Session就够了。
面试官问:Access Token和Refresh Token存储位置有什么讲究?
参考答案:
Access Token:
- 放内存(Vue/React状态),XSS拿不到
- 不放localStorage(XSS可读取)
Refresh Token:
- 放HttpOnly Cookie,XSS拿不到,CSRF难以利用
- 或放数据库,前端只存TokenId
为什么不放localStorage:
- XSS攻击可以读取localStorage
- 一旦被XSS,Token全泄露
七、总结
双令牌模式的核心价值:
- 风险隔离:Access Token泄露只影响30分钟
- 用户体验:Token过期无感知刷新
- 可控失效:Refresh Token存数据库,支持踢人/改密
- Token轮转:每次刷新生成新Token,进一步降低泄露风险
一句话总结:双Token把"长期有效"的风险转移到了可主动失效的Refresh Token上,实现了安全性和用户体验的平衡。
参考资料
- Sa-Token官方文档 - Token有效期详解
- RFC 6749 - OAuth 2.0 Authorization Framework
- JWT最佳实践 - RFC 8725
互动话题:你在项目中用的是单Token还是双Token?有没有遇到过Token泄露的问题?Refresh Token你是存数据库还是Redis?欢迎在评论区分享你的实践经验!
如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇
本文为【Java后端技术亮点】系列第1篇,持续更新中…