前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践

前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践
在这里插入图片描述
🌷 古之立大事者,不惟有超世之才,亦必有坚忍不拔之志
🎐 个人CSND主页——Micro麦可乐的博客
🐥《Docker实操教程》专栏以最新的Centos版本为基础进行Docker实操教程,入门到实战
🌺《RabbitMQ》专栏19年编写主要介绍使用JAVA开发RabbitMQ的系列教程,从基础知识到项目实战
🌸《设计模式》专栏以实际的生活场景为案例进行讲解,让大家对设计模式有一个更清晰的理解
🌛《开源项目》本专栏主要介绍目前热门的开源项目,带大家快速了解并轻松上手使用
🍎 《前端技术》专栏以实战为主介绍日常开发中前端应用的一些功能以及技巧,均附有完整的代码示例
✨《开发技巧》本专栏包含了各种系统的设计原理以及注意事项,并分享一些日常开发的功能小技巧
💕《Jenkins实战》专栏主要介绍Jenkins+Docker的实战教程,让你快速掌握项目CI/CD,是2024年最新的实战教程
🌞《Spring Boot》专栏主要介绍我们日常工作项目中经常应用到的功能以及技巧,代码样例完整
👍《Spring Security》专栏中我们将逐步深入Spring Security的各个技术细节,带你从入门到精通,全面掌握这一安全技术
如果文章能够给大家带来一定的帮助!欢迎关注、评论互动~

前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践

1. 前言

在我们前后端分离的应用中,常用的身份认证方案是基于 JWTJSON 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.jsimport axios from'axios';// Base Axios 实例const api = axios.create({baseURL:'/api',});// Token 存取functiongetAccessToken(){return localStorage.getItem('access_token');}functiongetRefreshToken(){return localStorage.getItem('refresh_token');}functionsetTokens({ 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 =[];functiononRefreshed(newToken){ subscribers.forEach(cb=>cb(newToken)); subscribers =[];}functionaddSubscriber(cb){ subscribers.push(cb);} api.interceptors.response.use(res=> res,error=>{const{ config, response }= error;if(response && response.status ===401&&!config._retry){if(isRefreshing){// 正在刷新,加入队列returnnewPromise(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}`;returnapi(config);}).catch(err=>{// 刷新失败,跳转登录 isRefreshing =false; window.location.href ='/login';return Promise.reject(err);});}return Promise.reject(error);});exportdefault api;

要点说明

isRefreshingsubscribers 用于解决多个并发 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 数据库与实体(存储用户可选)

这里就简单模拟用户,仅有用户名和密码为例

-- 用户表(简化)CREATETABLE user_account ( id BIGINTPRIMARYKEYAUTO_INCREMENT, username VARCHAR(50)UNIQUENOTNULL, password VARCHAR(255)NOTNULL);

5.3 Redis 存储 Refresh Token

我们用 ·Redis· 的 String,Key 为 refresh:{userId},Value 存 JSON { token, expireTime }

5.4 JWT 工具类

// JwtUtil.java@ComponentpublicclassJwtUtil{@Value("${jwt.secret}")privateString secret;@Value("${jwt.access.expire}")privatelong accessExpire;// ms@Value("${jwt.refresh.expire}")privatelong refreshExpire;// ms// 生成 Access Token(短期)publicStringgenerateAccessToken(Long userId){returnJwts.builder().setSubject(userId.toString()).setIssuedAt(newDate()).setExpiration(newDate(System.currentTimeMillis()+ accessExpire)).signWith(Keys.hmacShaKeyFor(secret.getBytes())).compact();}// 生成 Refresh Token(长期)publicStringgenerateRefreshToken(Long userId){returnJwts.builder().setSubject(userId.toString()).setIssuedAt(newDate()).setExpiration(newDate(System.currentTimeMillis()+ refreshExpire)).signWith(Keys.hmacShaKeyFor(secret.getBytes())).compact();}// 解析 TokenpublicClaimsparseToken(String token){returnJwts.parserBuilder().setSigningKey(secret.getBytes()).build().parseClaimsJws(token).getBody();}}

5.5 刷新服务

// AuthService.java@ServicepublicclassAuthService{@AutowiredprivateJwtUtil jwtUtil;@AutowiredprivateStringRedisTemplate redis;publicTokenslogin(String username,String password){// 1. 验证用户名密码(略,用 MyBatis-Plus 查询)Long userId =/* ... */;// 2. 生成双 TokenString accessToken = jwtUtil.generateAccessToken(userId);String refreshToken = jwtUtil.generateRefreshToken(userId);// 3. 保存到 RedisString key ="refresh:"+ userId; redis.opsForValue().set(key, refreshToken, jwtUtil.getRefreshExpire(),TimeUnit.MILLISECONDS);returnnewTokens(accessToken, refreshToken);}publicTokensrefresh(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)){thrownewRuntimeException("Refresh Token 无效或已过期");}// 3. 生成新 TokenString newAccess = jwtUtil.generateAccessToken(userId);String newRefresh = jwtUtil.generateRefreshToken(userId);// 4. 覆盖 Redis redis.opsForValue().set(key, newRefresh, jwtUtil.getRefreshExpire(),TimeUnit.MILLISECONDS);returnnewTokens(newAccess, newRefresh);}}

5.6 控制器Controller

// AuthController.java@RestController@RequestMapping("/api/auth")publicclassAuthController{@AutowiredprivateAuthService authService;@PostMapping("/login")publicTokenslogin(@RequestBodyLoginReq req){return authService.login(req.getUsername(), req.getPassword());}@PostMapping("/refresh")publicTokensrefresh(@RequestBodyMap<String,String> body){return authService.refresh(body.get("refreshToken"));}}// DTOs@DataclassLoginReq{privateString username, password;}@Data@AllArgsConstructorclassTokens{privateString accessToken;privateString 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 被盗用。

希望本文能帮助你快速在项目中落地无感刷新方案,如果你在实践过程中有任何疑问或更好的扩展思路,欢迎在评论区留言,最后希望大家一键三连给博主一点点鼓励!


Read more

计算机Java毕设实战-基于Spring Boot的教育机构师资资源管理系统设计与实现基于Web的师资管理系统设计与实现【完整源码+LW+部署说明+演示视频,全bao一条龙等】

计算机Java毕设实战-基于Spring Boot的教育机构师资资源管理系统设计与实现基于Web的师资管理系统设计与实现【完整源码+LW+部署说明+演示视频,全bao一条龙等】

java毕业设计-基于springboot的(源码+LW+部署文档+全bao+远程调试+代码讲解等) 博主介绍:✌️码农一枚 ,专注于大学生项目实战开发、讲解和毕业🚢文撰写修改等。全栈领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围::小程序、SpringBoot、SSM、JSP、Vue、PHP、Java、python、爬虫、数据可视化、大数据、物联网、机器学习等设计与开发。 主要内容:免费开题报告、任务书、全bao定制+中期检查PPT、代码编写、🚢文编写和辅导、🚢文降重、长期答辩答疑辅导、一对一专业代码讲解辅导答辩、模拟答辩演练、和理解代码逻辑思路。 特色服务内容:答辩必过班 (全程一对一技术交流,帮助大家顺利完成答辩,

Linux网络 | 理解Web路径 以及 实现一个简单的helloworld网页

Linux网络 | 理解Web路径 以及 实现一个简单的helloworld网页

前言:本节内容承接上节课的http相关的概念, 主要是实现一个简单的接收http协议请求的服务。这个程序对于我们理解后面的http协议的格式,报头以及网络上的资源的理解, 以及本节web路径等等都有着重要作用。 可以说我们就用代码来理解这些东西。 那么废话不多说, 现在开始我们的学习吧。         ps:本节内容建议先看一下上一篇文章http的相关概念哦:linux网络 | 深度学习http的相关概念-ZEEKLOG博客 目录  准备文件  makefile HttpServer.hpp 类内成员 封装sockfd start  ThreadRun  全部代码 运行结果 响应书写 Web路径  准备文件         首先准备文件: 这里面Httpserver.cc用来运行接收http请求的服务。 HttpServer.hpp用来定义http请求。Log.hpp就是一个打印日志的小组件, Socket.hpp同样是套接字的组件。 到使用直接调用相关接口即可。(Log.hpp和Socket.hpp如何实现不讲解, 如果想要知道

前端八股文面经大全:字节前端一面(2026-2-1)·面经深度解析

前端八股文面经大全:字节前端一面(2026-2-1)·面经深度解析

前言 大家好,我是木斯佳。 在这个春节假期,当大家都在谈论返乡、团圆与休息时,作为一名技术人,我的思考却不由自主地转向了行业的「冬」与「春」。 相信很多人都感受到了,在AI浪潮的席卷之下,前端领域的门槛在变高,纯粹的“增删改查”岗位正在肉眼可见地减少。曾经热闹非凡的面经分享,如今也沉寂了许多。但我们都知道,市场的潮水退去,留下的才是真正在踏实准备、努力沉淀的人。学习的需求,从未消失,只是变得更加务实和深入。 正值春节,也是复盘与规划的好时机。结合ZEEKLOG这次「春节代码贺新年」活动所提倡的“用技术视角记录春节、复盘成长”,我决定在这个假期持续更新专栏,帮助年后参加春招的同学。 这个专栏的初衷很简单:拒绝过时的、流水线式的PDF引流贴,专注于收集和整理当下最新、最真实的前端面试资料。 我会在每一份面经和八股文的基础上,尝试从面试官的角度去拆解问题背后的逻辑,而不仅仅是提供一份静态的背诵答案。无论你是校招还是社招,目标是中大厂还是新兴团队,只要是真实发生、有价值的面试经历,我都会在这个专栏里为你沉淀下来。 温馨提示:市面上的面经鱼龙混杂,

无需编程!Fun-ASR WebUI界面手把手操作教程

无需编程!Fun-ASR WebUI界面手把手操作教程 你是不是也遇到过这些情况:会议录音堆在文件夹里没时间听,客户语音留言转文字总出错,培训音频想整理成笔记却要花半天?别再复制粘贴到网页版工具、别再折腾Python环境、更别担心“CUDA out of memory”报错——今天这篇教程,专为完全不写代码的人准备。 Fun-ASR WebUI 是钉钉联合通义实验室推出的语音识别系统,由开发者“科哥”深度优化并封装成开箱即用的图形界面。它不是命令行里的冰冷指令,也不是需要配置10个参数才能跑起来的实验项目,而是一个像微信一样点点就能用的本地语音转文字工具。全程不需要安装Python包、不用改配置文件、不用查GPU型号——只要你会打开浏览器,就能把一段30分钟的采访音频,5分钟内变成带标点、分段清晰、数字自动规整的可编辑文本。 下面我将带你从零开始,像教朋友一样,一步步操作每一个按钮、解释每一处设置、避开所有新手踩坑点。你不需要懂“VAD”是什么,也不用知道“ITN”怎么拼,只需要跟着做,就能立刻上手。 1. 启动与访问:两步打开你的语音助手