首页 前端知识 全方位解析双 Token实现无感刷新:用 Spring Boot Vue Redis 构建高安全认证体系

全方位解析双 Token实现无感刷新:用 Spring Boot Vue Redis 构建高安全认证体系

2025-03-13 15:03:21 前端知识 前端哥 811 283 我要收藏

前言:

随着Web应用需求的增加,如何保障用户数据和信息的安全,成为了开发者关注的重要问题。传统的单Token认证方法虽然简便,但在长时间使用或高频请求下,可能带来一定的安全隐患。双Token身份认证机制提供了一种更加安全且高效的方式,本文将详细介绍如何在Spring Boot和Vue中实现双Token认证。同时在单token进行操作时,也会遇到token到期而需要频繁登录的问题,使用双token就能很好地解决这个问题!

双Token介绍:

双Token认证机制是基于OAuth2.0标准的一种增强型身份认证方式,主要通过两个Token:访问Token(Access Token)刷新Token(Refresh Token),来分别处理认证与授权问题。

单Token VS 双Token

单Token:用户登录后,返回一个Token,用户每次请求都需要携带此Token进行身份认证。缺点是,Token一旦被盗取或泄露,恶意用户可以长时间访问系统,且Token有效期通常较长,若用户频繁使用系统,Token有效期过长也可能造成不便。

双Token:通过使用两个Token来提高安全性和用户体验。访问Token:短期有效,用于每次请求时验证身份,确保用户认证的实时性。刷新Token:长期有效,用于刷新过期的访问Token,避免频繁登录。

具体流程:

  1. 用户登录后生成token(过期时间短)和refreshToken(过期时间长),token和refreshToken先返回前端并保存到localstroage中,同时再将refreshToken保存到Redis中
  2. 前端请求头中携带token进行普通路径的方法调用
  3. 当token失效,过滤器中发现错误并返回前端
  4. 前端根据错误请求头携带上refreshToken并调用后端中更新token的方法
  5. 此时再次进入过滤器中,token还是报错,但是此时能检查到refreshToken,调用更新token的方法
  6. 在更新token的方法中,先检查当前的refreshToken是否存在与Redis中,若不存在,则说明refreshToken也过期了,用户需要重新登录
  7. 若Redis中存在refreshToken与当前的refreshToken对应,则说明是当前的短token过期了,重新生成token和refreshToken并返回前端,同时将新refreshToken存到Redis中?

详细操作:

首先这是我们暂时的登录与过滤器中的流程。只实现了单Token的生成及校验。

@Override
public ResponseResult login(User user) {
//1.构造用户名密码认证信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getPhoneNumber(), user.getPassword());
//2.使用SpringSecurity 中用于封装用户名密码认证信息的UsernamePasswordAuthenticationToken来进行认证
//这里会进行账号密码校验,不成功会报403
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//3.认证不通过报错
if(Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
//4.认证通过则生成token
LoginUser loginUser= (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String role = loginUser.getUser().getRole();
String token = JwtUtil.createJWT(userId,role);//将userId进行token生成
//4.1如果用户被禁用则无法登录
String userStatus = userService.selectUserStatusByUserId(Long.valueOf(userId));
if(userStatus.equals("banned")){
return ResponseResult.error("您已被禁用,无法登录");
}
//5.封装数据到Redis中
redisCache.setCacheObject(LOGIN_USER_KEY+userId,loginUser,LOGIN_USER_TTL, TimeUnit.MINUTES);
//6.最后将token返回前端
HashMap<String, String> map = new HashMap<>();
map.put("token",token);
return new ResponseResult(200,"登录成功",map);
}
复制
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
response.setContentType("application/json;charset=UTF-8");
//1.获取请求头中的token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//2.token为空,直接放行
filterChain.doFilter(request, response);
return;
}
//3.token不为空
//3.1解析token中的id
String id;
try {
Claims claims = JwtUtil.parseJWT(token);
id = claims.getSubject();
} catch (Exception e) {
//TODO使用统一异常类封装
ResponseResult<Object> responseResult = new ResponseResult(401, "请重新登录",null);
String s = JSON.toJSONString(responseResult);
response.getWriter().write(s);
return;
}
//4.根据id从redis中获取用户信息
String key = LOGIN_USER_KEY + id;
LoginUser loginUser = redisCache.getCacheObject(key);
if(Objects.isNull(loginUser)){
ResponseResult<Object> responseResult = new ResponseResult(401, "请重新登录",null);
String s = JSON.toJSONString(responseResult);
response.getWriter().write(s);
return;
}
//5.将用户信息存入SecurityContextHolder
//TODO获取当前用户权限信息封装到Authentication 直接从LoginUser中获取即可
//5.1封装用户信息到Authentication
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
//5.2将信息存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//6.放行
filterChain.doFilter(request,response);
}
复制
1.添加JWT工具类方法

之前JWT是有只生成一个短token的方法,短Token的持续时间是一个小时,现在添加一个生成长Token的方法

//有效期为
public static final Long JWT_TTL = 60 *60 *1000L;// 一小时
public static final Long REFRESH_JWT_TTL = 7*60*60*1000L; //七天
复制

token是短token,refreshToken是长Token,通过调用不同的方法创建短Token和长Token

/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject,null, getUUID());// 设置过期时间
return builder.compact();
}
/**
* @author 小菜
* @date 2024/12/10
* @description 生成refreshToken
**/
public static String createRefreshToken(String subject){
JwtBuilder builder = getJwtBuilder(subject, REFRESH_JWT_TTL, getUUID());
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
// .claim("role",role)
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("xc") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}
复制
2.修改登录流程

在登录成功后将token和refreshToken都返回前端并将refreshToken保存到Redis中以便后续查询使用

@Override
public ResponseResult login(User user) {
//1.构造用户名密码认证信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getPhoneNumber(), user.getPassword());
//2.使用SpringSecurity 中用于封装用户名密码认证信息的UsernamePasswordAuthenticationToken来进行认证
//这里会进行账号密码校验,不成功会报403
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//3.认证不通过报错
if(Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
//4.认证通过则生成双token
LoginUser loginUser= (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
// String role = loginUser.getUser().getRole();
String token = JwtUtil.createJWT(userId);//将userId进行token生成
String refreshToken = JwtUtil.createRefreshToken(userId);//生成长token
//4.1如果用户被禁用则无法登录
String userStatus = userService.selectUserStatusByUserId(Long.valueOf(userId));
if(userStatus.equals("banned")){
return ResponseResult.error("您已被禁用,无法登录");
}
//5.封装数据到Redis中
redisCache.setCacheObject(LOGIN_USER_KEY+userId,loginUser,LOGIN_USER_TTL, TimeUnit.MINUTES);
redisCache.setCacheObject(REFRESH_CODE_KEY+userId,refreshToken,REFRESH_CODE_TTL,TimeUnit.MINUTES);
//6.最后将token返回前端
HashMap<String, String> map = new HashMap<>();
map.put("token",token);
map.put("refreshToken",refreshToken);
return new ResponseResult(200,"登录成功",map);
}
复制
3.修改拦截器中的检验方法

原先是当识别token失败,就直接返回错误信息,现在识别失败后,还要检查请求头中是否携带refreshToken,如果携带refreshToken则说明是前端要向后端请求一个新的短token,对这个refreshToken进行验证,验证成功则通过取请求生成一个新token,如果校验refreshToken的过程中也出错了,那说明refreshToken也失效了,用户需要重新登录。

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
response.setContentType("application/json;charset=UTF-8");
//1.获取请求头中的token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//2.token为空,直接放行
filterChain.doFilter(request, response);
return;
}
//3.token不为空
//3.1解析token中的id
String id;
try {
Claims claims = JwtUtil.parseJWT(token);
id = claims.getSubject();
} catch (Exception e) {
//3.2token过期,检查refreshToken是否过期
String refreshToken = request.getHeader("refreshToken");
if(StringUtils.hasText(refreshToken)){
try {
//存在refreshToken去调用方法检验refreshToken并生成新token
id = JwtUtil.parseJWT(refreshToken).getSubject();
} catch (Exception ex) {
ResponseResult<Object> responseResult = new ResponseResult(401, "请重新登录",null);
String s = JSON.toJSONString(responseResult);
response.getWriter().write(s);
return;
}
}else{
//不存在refreshToken直接报错
ResponseResult<Object> responseResult = new ResponseResult(401, "请重新登录",null);
String s = JSON.toJSONString(responseResult);
response.getWriter().write(s);
return;
}
}
//4.根据id从redis中获取用户信息
String key = LOGIN_USER_KEY + id;
LoginUser loginUser = redisCache.getCacheObject(key);
if(Objects.isNull(loginUser)){
ResponseResult<Object> responseResult = new ResponseResult(401, "请重新登录",null);
String s = JSON.toJSONString(responseResult);
response.getWriter().write(s);
return;
}
//5.将用户信息存入SecurityContextHolder
//TODO获取当前用户权限信息封装到Authentication 直接从LoginUser中获取即可
//5.1封装用户信息到Authentication
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
//5.2将信息存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//6.放行
filterChain.doFilter(request,response);
}
复制
4、修改前端拦截器

当后端返回指定错误信息,则说明是短token失效了,前端则尝试将refreshToken添加到请求头中向后端请求一个重新生辰短token的方法并保存当前请求的情况,等获取新的token之后继续请求,如果此时再次返回错误,那就是refreshToken也过期了,将localstroage中的信息清除后回到登录界面重新进行登录。

axios.interceptors.response.use(
response => {
// 如果 Token 过期(401 错误)
if (response && response.data.code === 401 && response.data.msg === "请重新登录") {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
// 尝试使用 refreshToken 刷新 token
return axios.post('http://localhost:8889/common/refreshToken', { refreshToken: refreshToken }, {
headers: {
'token': localStorage.getItem('token'), // 也可以传递当前的 token 作为头部
'refreshToken':refreshToken
}
}).then(refreshResponse => {
if (refreshResponse && refreshResponse.data.code === 200) {
// console.log("回复token"+refreshResponse.data.data)
// 获取新的 token 并更新到 localStorage
const newToken = refreshResponse.data.data;
localStorage.setItem('token', newToken);
// 更新请求头中的 token
axios.defaults.headers['token'] = newToken;
// 重新请求原来的接口
response.config.headers['token'] = newToken;
return axios(response.config); // 返回新的请求
} else {
// console.log("回复"+refreshResponse)
// 刷新失败,跳转到登录页面
localStorage.removeItem('userName');
localStorage.removeItem('userPicture');
localStorage.removeItem('role');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
router.push('/login');
ElMessage.error('刷新 Token 失败,请重新登录');
}
});
} else {
// 没有 refreshToken,跳转到登录页面
localStorage.removeItem('userName');
localStorage.removeItem('userPicture');
localStorage.removeItem('role');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
router.push('/login');
ElMessage.error('刷新 Token 失败,请重新登录');
}
}
return response; // 确保返回 response
},
// error => {
// return Promise.reject(error); // 将错误继续传递
// }
);
复制
5、添加生成短Token的方法

生成短token的方法已经写上注释了,自己看~

@Override
public String refreshToken(String refreshToken) {
//1.检验refreshToken是否过期
String userId;
try {
userId = JwtUtil.parseJWT(refreshToken).getSubject();
} catch (Exception e) {
throw new RuntimeException("refreshToken已过期");
}
//2.refreshToken不过期,检验Redis是否存在token
String key = REFRESH_CODE_KEY+userId;
String redisRefreshToken = redisCache.getCacheObject(key);
//2.1不存在,报错
if(!redisRefreshToken.equals(refreshToken)){
throw new RuntimeException("refreshToken不匹配");
}
//3.存在,生成新token并返回
String newToken = JwtUtil.createJWT(userId);
return newToken;
}
复制

总结:

双Token认证机制为Web应用提供了更强的安全性和更好的用户体验。通过合理的配置和管理,访问Token和刷新Token分别承担不同的功能,有效防止了Token被长期滥用的风险,并减少了用户频繁登录的困扰。尽管实现较为复杂,但其带来的好处是显而易见的。通过本文的Spring Boot与Vue示例,希望开发者能够更好地理解并实施双Token认证机制,提升应用的安全性与稳定性。

本篇文章主要提供一个实现双Token的思路,代码也只是提供一个思路,并不能实现拿来即用的效果,思路仅供参考,有问题的地方还请指正,如果这篇文章有帮到你的话,还请点赞支持一下吧~你的支持就是我的最大动力!!!

转载请注明出处或者链接地址:https://www.qianduange.cn//article/23497.html
标签
评论
会员中心 联系我 留言建议 回顶部
浏览器升级提示:您的浏览器版本较低,建议您立即升级为知了极速浏览器,极速、安全、简约,上网速度更快!立即下载
复制成功!