1. 前言
在我们前后端分离的应用中,常用的身份认证方案是基于 jwt
(json web token)。在保证安全性的同时,短生命周期的 access token
又会带来频繁登录的体验痛点。为了解决这个问题,我们引入 refresh token
并结合无感刷新机制,让客户端在 access token
过期时自动刷新,而无需用户手动重新登录,从而最大化提升用户体验。
小伙伴们可以通过本文,快速掌握无感 token
刷新的原理以及实现方式
2. 为什么要无感刷新
在基于token
的用户认证系统中,通常会设计两种token
:
access token:用于访问资源,有效期短(通常15-30分钟)
refresh token:用于获取新access token
,有效期长(通常7天)
传统token机制存在两大痛点:
频繁强制退出:
access token
过期时用户需重新登录
安全隐患:延长access token
有效期会增加安全风险
无感刷新解决了这些问题:
用户体验优先
access token 常设很短(如 5–15 分钟),若不自动刷新,登录态会频繁过期,用户被迫“重新登录”,体验极差
安全与性能平衡
短生命周期的 access token 能减少被截获滥用的风险
结合 refresh token(相对较长有效期),可以在安全与便捷间找到最佳点
前后端解耦
通过前端拦截器统一处理过期场景,无须在各业务请求中散落重复逻辑
后端专注提供刷新接口与失效策略,无需关心前端实现细节
3 无感刷新原理
3.1 无感刷新流程
3.2 关键技术点
双 token 机制
access token:短时有效,携带用户身份和权限
refresh token:长期有效,专用于换取新的 access token
拦截与重试
1、前端在每次
api
请求中携带access token
;
2、若响应为401 unauthorized
(或后端自定义过期码),前端拦截器自动调用刷新token接口,用refresh token
获取新一对 token;
3、获取成功后,前端重新发起失败的原始请求,用户无感知。
后端安全策略
将 refresh token
写入 redis
,并在刷新时做一次性或者滑动过期(可选)校验;
旧 refresh token
刷新后失效,防止被盗用。
4、前端实现
下面以 axios
为例演示拦截器逻辑。我们将 tokens
保存在 localstorage
或者更安全的 [httponly cookie
] 中(此处示例用 localstorage 方便演示)
// auth.js import axios from 'axios'; // base axios 实例 const api = axios.create({ baseurl: '/api', }); // token 存取 function getaccesstoken() { return localstorage.getitem('access_token'); } function getrefreshtoken() { return localstorage.getitem('refresh_token'); } function settokens({ accesstoken, refreshtoken }) { localstorage.setitem('access_token', accesstoken); localstorage.setitem('refresh_token', refreshtoken); } // 请求拦截:自动附带 access token api.interceptors.request.use(config => { const token = getaccesstoken(); if (token) config.headers['authorization'] = `bearer ${token}`; return config; }); // 响应拦截:遇到 401 刷新并重试 let isrefreshing = false; let subscribers = []; function onrefreshed(newtoken) { subscribers.foreach(cb => cb(newtoken)); subscribers = []; } function addsubscriber(cb) { subscribers.push(cb); } api.interceptors.response.use( res => res, error => { const { config, response } = error; if (response && response.status === 401 && !config._retry) { if (isrefreshing) { // 正在刷新,加入队列 return new promise(resolve => { addsubscriber(token => { config.headers['authorization'] = `bearer ${token}`; resolve(api(config)); }); }); } config._retry = true; isrefreshing = true; // 调用刷新接口 return api.post('/auth/refresh', { refreshtoken: getrefreshtoken() }) .then(res => { const { accesstoken, refreshtoken } = res.data; settokens({ accesstoken, refreshtoken }); isrefreshing = false; onrefreshed(accesstoken); // 重试原请求 config.headers['authorization'] = `bearer ${accesstoken}`; return api(config); }) .catch(err => { // 刷新失败,跳转登录 isrefreshing = false; window.location.href = '/login'; return promise.reject(err); }); } return promise.reject(error); } ); export default api;
要点说明
isrefreshing
和subscribers
用于解决多个并发401
时只发送一次刷新请求;_retry
标记避免无限循环;
刷新失败后,需清除本地登录态并跳转到登录页。
5. 后端实现
5.1 基础依赖(pom.xml)
<dependencies> <!-- spring boot starter web --> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-web</artifactid> </dependency> <!-- mybatis-plus --> <dependency> <groupid>com.baomidou</groupid> <artifactid>mybatis-plus-boot-starter</artifactid> <version>3.5.3.1</version> </dependency> <!-- mysql 驱动 --> <dependency> <groupid>mysql</groupid> <artifactid>mysql-connector-java</artifactid> </dependency> <!-- redis --> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-data-redis</artifactid> </dependency> <!-- jwt --> <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> </dependencies>
5.2 数据库与实体(存储用户可选)
这里就简单模拟用户,仅有用户名和密码为例
-- 用户表(简化) create table user_account ( id bigint primary key auto_increment, username varchar(50) unique not null, password varchar(255) not null );
5.3 redis 存储 refresh token
我们用 ·redis· 的 string,key 为 refresh:{userid}
,value 存 json { token, expiretime }
5.4 jwt 工具类
// jwtutil.java @component public class jwtutil { @value("${jwt.secret}") private string secret; @value("${jwt.access.expire}") private long accessexpire; // ms @value("${jwt.refresh.expire}") private long refreshexpire; // ms // 生成 access token(短期) public string generateaccesstoken(long userid) { return jwts.builder() .setsubject(userid.tostring()) .setissuedat(new date()) .setexpiration(new date(system.currenttimemillis() + accessexpire)) .signwith(keys.hmacshakeyfor(secret.getbytes())) .compact(); } // 生成 refresh token(长期) public string generaterefreshtoken(long userid) { return jwts.builder() .setsubject(userid.tostring()) .setissuedat(new date()) .setexpiration(new date(system.currenttimemillis() + refreshexpire)) .signwith(keys.hmacshakeyfor(secret.getbytes())) .compact(); } // 解析 token public claims parsetoken(string token) { return jwts.parserbuilder() .setsigningkey(secret.getbytes()) .build() .parseclaimsjws(token) .getbody(); } }
5.5 刷新服务
// authservice.java @service public class authservice { @autowired private jwtutil jwtutil; @autowired private stringredistemplate redis; public tokens login(string username, string password) { // 1. 验证用户名密码(略,用 mybatis-plus 查询) long userid = /* ... */; // 2. 生成双 token string accesstoken = jwtutil.generateaccesstoken(userid); string refreshtoken = jwtutil.generaterefreshtoken(userid); // 3. 保存到 redis string key = "refresh:" + userid; redis.opsforvalue().set(key, refreshtoken, jwtutil.getrefreshexpire(), timeunit.milliseconds); return new tokens(accesstoken, refreshtoken); } public tokens refresh(string refreshtoken) { // 1. 解析 claims claims = jwtutil.parsetoken(refreshtoken); long userid = long.parselong(claims.getsubject()); // 2. redis 校验 string key = "refresh:" + userid; string cached = redis.opsforvalue().get(key); if (cached == null || !cached.equals(refreshtoken)) { throw new runtimeexception("refresh token 无效或已过期"); } // 3. 生成新 token string newaccess = jwtutil.generateaccesstoken(userid); string newrefresh = jwtutil.generaterefreshtoken(userid); // 4. 覆盖 redis redis.opsforvalue().set(key, newrefresh, jwtutil.getrefreshexpire(), timeunit.milliseconds); return new tokens(newaccess, newrefresh); } }
5.6 控制器controller
// authcontroller.java @restcontroller @requestmapping("/api/auth") public class authcontroller { @autowired private authservice authservice; @postmapping("/login") public tokens login(@requestbody loginreq req) { return authservice.login(req.getusername(), req.getpassword()); } @postmapping("/refresh") public tokens refresh(@requestbody map<string,string> body) { return authservice.refresh(body.get("refreshtoken")); } } // dtos @data class loginreq { private string username, password; } @data @allargsconstructor class tokens { private string accesstoken; private string refreshtoken; }
5.7 jwt 验证过滤器
由于验证并非本文的重点,小伙伴们可以参考博主的 《spring security》专栏学习,这里仅提供思路:
在每次请求拦截中,解析 access token
并将用户信息放入 securitycontext
,若过期则交由前端刷新逻辑处理。
6. 结语
本文详细介绍了 无感 token 刷新
的核心原理,以及前端 axios
拦截器与后端 spring boot + mybatis-plus + redis
的完整示例代码。通过双 token、redis 校验与拦截重试,你可以在保证安全性的同时,给用户带来 无感登录过期刷新 的体验
后续可继续优化:
- refresh token 滑动过期:每次刷新延长有效期;
- refresh token 一次性使用:每个旧 token 只能刷新一次;
- 前端多 tab 协调:同域下可共享刷新状态,避免重复刷新;
- 安全加固:结合 ip、ua 风控,防止 token 被盗用。
到此这篇关于前端与spring boot后端无感token 刷新的完整实例代码的文章就介绍到这了,更多相关spring boot无感token刷新内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论