前言:

随着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的思路,代码也只是提供一个思路,并不能实现拿来即用的效果,思路仅供参考,有问题的地方还请指正,如果这篇文章有帮到你的话,还请点赞支持一下吧~你的支持就是我的最大动力!!!

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐