前言:
随着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,避免频繁登录。
具体流程:
- 用户登录后生成token(过期时间短)和refreshToken(过期时间长),token和refreshToken先返回前端并保存到localstroage中,同时再将refreshToken保存到Redis中
- 前端请求头中携带token进行普通路径的方法调用
- 当token失效,过滤器中发现错误并返回前端
- 前端根据错误请求头携带上refreshToken并调用后端中更新token的方法
- 此时再次进入过滤器中,token还是报错,但是此时能检查到refreshToken,调用更新token的方法
- 在更新token的方法中,先检查当前的refreshToken是否存在与Redis中,若不存在,则说明refreshToken也过期了,用户需要重新登录
- 若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的思路,代码也只是提供一个思路,并不能实现拿来即用的效果,思路仅供参考,有问题的地方还请指正,如果这篇文章有帮到你的话,还请点赞支持一下吧~你的支持就是我的最大动力!!!