开发者

springboot+vue3无感知刷新token实战教程

目录
  • 一、Java后端
    • 1、token构造实现类
      • ①验证码方式实现类
      • ②刷新token方式实现类
    • 2、token相关操作:setCookie
      • ①createToken
      • ②refreshToken
      • ③异常码
  • 二、前端(vue3+axIOS)
    • 总结

      web网站中,前后端交互时,通常使用token机制来做认证,token一般会设置有效期,当token过了有效期后,用户需要重新登录授权获取新的token,但是某些业务场景下,用户不希望频繁的进行登录授权,但是安全考虑,token的有效期不能设置太长时间,所以有了刷新token的设计,无感知刷新token的机制更进一步优化了用户体验,本文是博主实际业务项目中基于springboot和vue3无感知刷新token的代码实战。

      首先介绍无感知刷新token的实现思路:

      ①首次授权颁发token时,我们通过后端给前端请求response中写入两种cookie

      • - Access_token
      • - refresh_token(超时时间比access_token长一些)

      需要注意:

      -后端setCookie时httpOnly=true(限制cookie只能被http请求携带使用,不能被js操作)

      -前端axios请求参数withCredentials=true(http请求时,自动携带token)

      • ②access_token失效时,抛出特殊异常,前后端约定http响应码(401),此时触发刷新token逻辑
      • ③前段http请求钩子中,如果出现http响应码为401时,立即触发刷新token逻辑,同时缓存后续请求,刷新token结束后,依次续发缓存中的请求

      一、java后端

      后端java框架使用springboot,spring-security

      登录接口:

      /**
       * @author lichenhao
       * @date 2023/2/8 17:41
       */
      @RestController
      public class AuthController {
      
          /**
           * 登录方法
           *
           * @param loginBody 登录信息
           * @return 结果
           */
          @PostMapping("/oauth")
          public AJAXResult login(@RequestBody LoginBody loginBody) {
              ITokenGranter granter = TokenGranterBuilder.getGranter(loginBody.getGrantType());
              return granter.grant(loginBody);
          }
      }
      
      
      import lombok.Data;
      
      /**
       * 用户登录对象
       *
       * @author lichenhao
       */
      @Data
      public class LoginBody {
      
          /**
           * 用户名
           */
          private String username;
      
          /**
           * 用户密码
           */
          private String password;
      
          /**
           * 验证码
           */
          private String code;
      
          /**
           * 唯一标识
           */
          private String uuid;
      
          /*
           * grantType 授权类型
           * */
          private String grantType;
      
          /*
          * 是否直接强退该账号登陆的其他客户端
          * */
          private Boolean forceLogoutFlag;
      }

      token构造接口类和token实现类构造器如下:

      /**
       * @author lichenhao
       * @date 2023/2/8 17:29
       * <p>
       * 获取token
       */
      public interface ITokenGranter {
      
          AjaxResult grant(LoginBody loginBody);
      }
      
      
      /**
       * @author lichenhao
       * @date 2023/2/8 17:29
       */
      @AllArgsConstructor
      public class TokenGranterBuilder {
      
          /**
           * TokenGranter缓存池
           */
          private static final Map<String, ITokenGranter> GRANTER_POOL = new ConcurrentHashMap<>();
      
          static {
              GRANTER_POOL.put(CaptchaTokenGranter.GRANT_TYPE, SpringUtils.getBean(CaptchaTokenGranter.class));
              GRANTER_POOL.put(RefreshTokenGranter.GRANT_TYPE, SpringUtils.getBean(RefreshTokenGranter.class));
          }
      
          /**
           * 获取TokenGranter
           *
           * @param grantType 授权类型
           * @return ITokenGranter
           */
          public static ITokenGranter getGranter(String grantType) {
              ITokenGranter tokenGranter = GRANTER_POOL.get(StringUtils.toStr(grantType, PasswordTokenGranter.GRANT_TYPE));
              if (tokenGranter == null) {
                  throw new ServiceException("no grantType was found");
              } else {
                  return tokenGranter;
              }
          }
      
      }

      这里通过LoginBody的grantType属性,指定实际的token构造实现类;同时,需要有token

      本文我们用到了验证码方式和刷新token方式,如下:

      1、token构造实现类

      ①验证码方式实现类

      /**
       * @author lichenhao
       * @date 2023/2/8 17:32
       */
      @Component
      public class CaptchaTokenGranter implements ITokenGranter {
      
          public static final String GRANT_TYPE = "captcha";
      
          @Autowired
          private SysLoginService loginService;
      
          @Override
          public AjaxResult grant(LoginBody loginBody) {
              String username = loginBody.getUsername();
              String code = loginBody.getCode();
              String password = loginBody.getPassword();
              String uuid = loginBody.getUuid();
              Boolean forceLogoutFlag = loginBody.getForceLogoutFlag();
      
              AjaxResult ajaxResult = validateLoginBody(username, password, code, uuid);
              // 验证码
              loginService.validateCaptcha(username, code, uuid);
              // 登录
              loginService.login(username, password, uuid, forceLogoutFlag);
              // 删除验证码
              loginService.deleteCaptcha(uuid);
              return ajaxResult;
          }
      
          private AjaxResult validateLoginBody(String username, String password, String code, String uuid) {
              if (StringUtils.isBlank(username)) {
                  return AjaxResult.error("用户名必填");
              }
              if (StringUtils.isBlank(password)) {
                  return AjaxResult.error("密码必填");
              }
              if (StringUtils.isBlank(code)) {
                  return AjaxResult.error("验证码必填");
              }
              if (StringUtils.isBlank(uuid)) {
                  return AjaxResult.error("uuid必填");
              }
              return AjaxResult.success();
          }
      }
      
      
          /**
           * 登录验证
           *
           * @param username 用户名
           * @param password 密码
           * @return 结果
           */
          public void login(String username, String password, String uuid, Boolean forceLogoutFlag) {
              // 校验basic auth
              IClientDetails iClientDetails = tokenService.validBasicAuth();
              // 用户验证
              Authentication authentication = null;
              try {
                  UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
                  AuthenticationContextHolder.setContext(authenticationToken);
                  // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
                  authentication = authenticationManager.authenticate(authenticationToken);
              } catch (Exception e) {
                  if (e instanceof BadCredentialsException) {
                      AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                      throw new UserPasswordNotMatchException();
                  } else {
                      AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                      throw new ServiceException(e.getMessage());
                  }
              } finally {
                  AuthenticationContextHolder.clearContext();
              }
              LoginUser loginUser = (LoginUser) authentication.getPrincipal();
              tokenService.setUserAgent(loginUser);
              Long customerId = loginUser.getUser().getCustomerId();
              Boolean singleClientFlag = SystemConfig.isSingleClientFlag();
              if(customerId != null){
                  Customer customer = customerService.selectCustomerById(customerId);
                  singleClientFlag = customer.getSingleClientFlag();
                  log.info(String.format("客户【%s】单账号登录限制开关:%s", customer.getCode(), singleClientFlag));
              }
              if(singleClientFlag){
                  List<SysUserOnline> userOnlineList = userOnlineService.getUserOnlineList(null, username);
         python         if(CollectionUtils.isNotEmpty(userOnlineList)){
                      if(forceLogoutFlag != null && forceLogoutFlag){
                          // 踢掉其他使用该账号登陆的客户端
                          userOnlineService.forceLogoutBySysUserOnlineList(userOnlineList);
                      }else{
                          throw new ServiceException("【" + username + "】已登录,是否仍然登陆", 400);
                      }
                  }
              }
              // 生成token
              tokenService.createToken(iClientDetails, loginUser, uuid);
              AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
              recordLoginInfo(loginUser.getUserId());
          }

      ②刷新token方式实现类

      /**
       * @author lichenhao
       * @date 2023/2/8 17:35
       */
      @Component
      public class RefreshTokenGranter implements ITokenGranter {
      
          public static final String GRANT_TYPE = "refresh_token";
      
          @Autowired
          private TokenService tokenService;
      
          @Override
          public AjaxResult grant(LoginBody loginBody) {
              tokenService.refreshToken();
              return AjaxResult.success();
          }
      }

      2、token相关操作:setCookie

      ①createToken

          /**
           * 创建令牌
           * 注意:access_token和refresh_token 使用同一个tokenId
           */
          public void createToken(IClientDetails clientDetails, LoginUser loginUser, String tokenId) {
      
              if(loginUser == null){
                  throw new ForbiddenException("用户信息无效,请重新登陆!");
              }
      
              loginUser.setTokenId(tokenId);
      
              String username = loginUser.getUsername();
              String clientId = clientDetails.getClientId();
      
              // 设置jwt要携带的用户信息
              Map<String, Object> claimsMap = new HashMap<>();
              initClaimsMap(claimsMap, loginUser);
      
              long nowMillis = System.currentTimeMillis();
              Date now = new Date(nowMillis);
      
              int accessTokenValidity = clientDetails.getAccessTokenValidity();
              long accessTokenExpMillis = nowMillis + accessTokenValidity * MILLIS_SECOND;
              Date accessTokenExpDate = new Date(accessTokenExpMillis);
              String accessToken = createJwtToken(SecureConstant.ACCESS_TOKEN, accessTokenExpDate, now, JWT_TOKEN_SECRET, claimsMap, clientId, tokenId, username);
      
              int refreshTokenValidity = clientDetails.getRefreshTokenValidity();
              long refreshTokenExpMillis = nowMillis + refreshTokenValidity * MILLIS_SECOND;
              Date refreshTokenExpDate = new Date(refreshTokenExpMillis);
              String refreshToken = createJwtToken(SecureConstant.REFRESH_TOKEN, refreshTokenExpDate, now, JWT_REFRESH_TOKEN_SECRET, claimsMap, clientId, tokenId, username);
      
              // 写入cookie中
              HttpServletResponse response = ServletUtils.getResponse();
              WebUtil.setCookie(response, SecureConstant.ACCESS_TOKEN, accessToken, accessTokenValidity);
              WebUtil.setCookie(response, SecureConstant.REFRESH_TOKEN, refreshToken, refreshTokenValidity);
      
              //插入缓存(过期时间为最长过期时间=refresh_token的过期时间 理论上,保持操作的情况下,一直会被刷新)
              loginUser.setLoginTime(nowMillis);
              loginUser.setExpireTime(refreshTokenExpMillis);
              updateUserCache(loginUser);
          }
      
          private void initClaimsMap(Map<String, Object> claims, LoginUser loginUser) {
              // 添加jwt自定义参数
          }
      
          /**
           * 生成jwt token
           *
           * @param jwtTokenType token类型:access_token、refresh_token
           * @param expDate      token过期日期
           * @param now          当前日期
           * @param signKey      签名key
           * @param claimsMap    jwt自定义信息(可携带额外的用户信息)
           * @param clientId     应用id
           * @param tokenId      token的唯一标识(建议同一组 access_token、refresh_token 使用一个)
           * @param subject      jwt下发的用户标识
           * @return token字符串
           */
          private String createJwtToken(String jwtTokenType, Date expDate, Date now, String signKey, Map<String, Object> claimsMap, String clientId, String tokenId, String subject) {
      
              JwtBuilder jwtBuilder = Jwts.builder().setHeaderParam("typ", "JWT")
                      .setId(tokenId)
                      .setSubject(subject)
                      .signWith(SignatureAlgorithm.HS512, signKey);
      
              //设置JWT参数(user维度)
              claimsMap.forEach(jwtBuilder::claim);
      
              //设置应用id
              jwtBuilder.claim(SecureConstant.CLAIMS_CLIENT_ID, clientId);
      
              //设置token type
              jwtBuilder.claim(SecureConstant.CLAIMS_TOKEN_TYPE, jwtTokenType);
      
              //添加Token过期时间
              jwtBuilder.setExpiration(expDate).setNotBefore(now);
              return jwtBuilder.compact();
          }
      
          /*
           * 更新缓存中的用户信息
           * */
          public void updateUserCache(LoginUser loginUser) {
              // 根据tokenId将loginUser缓存
              String userKey = getTokenKey编程(loginUser.getTokenId());
              RedisService.setCacheObjec编程t(userKey, loginUser, parseIntByLong(loginUser.getExpireTime() - loginUser.getLoginTime()), TimeUnit.MILLISECONDS);
          }
      
          private String getTokenKey(String uuid) {
              return "login_tokens:" + uuid;
          }

      ②refreshToken

          /**
           * 刷新令牌有效期
           */
          public void refreshToken() {
              // 从cookie中拿到refreshToken
              String refreshToken = WebUtil.getCookieVal(ServletUtils.getRequest(), SecureConstant.REFRESH_TOKEN);
              if (StringUtils.isBlank(refreshToken)) {
                  throw new ForbiddenEx编程客栈ception("认证失败!");
              }
              // 验证 refreshToken 是否有效
              Claims claims = parseToken(refreshToken, JWT_REFRESH_TOKEN_SECRET);
              if (claims == null) {
                  throw new ForbiddenException("认证失败!");
              }
              String clientId = StringUtils.toStr(claims.get(SecureConstant.CLAIMS_CLIENT_ID));
              String tokenId = claims.getId();
              LoginUser loginUser = getLoginUserByTokenId(tokenId);
              if(loginUser == null){
                  throw new ForbiddenException("用户信息无效,请重新登陆!");
              }
              IClientDetails clientDetails = getClientDetailsService().loadClientByClientId(clientId);
              // 删除原token缓存
              delLoginUserCache(tokenId);
              // 重新生成token
              createToken(clientDetails, loginUser, IdUtils.simpleUUID());
          }
      
          /**
           * 根据tokenId获取用户信息
           *
           * @return 用户信息
           */
          public LoginUser getLoginUserByTokenId(String tokenId) {
              String userKey = getTokenKey(tokenId);
              LoginUser user = redisService.getCacheObject(userKey);
              return user;
          }
      
          /**
           * 删除用户缓存
           */
          public void delLoginUserCache(String tokenId) {
              if (StringUtils.isNotEmpty(tokenId)) {
                  String userKey = getTokenKey(tokenId);
                  redisService.deleteObject(userKey);
              }
          }

      ③异常码

      • 401:access_token无效,开始刷新token逻辑
      • 403:refresh_token无效,或者其他需要跳转登录页面的场景

      二、前端(vue3+axios)

      // 创建axios实例
      const service = axios.create({
          // axios中请求配置有baseURL选项,表示请求URL公共部分
          baseURL: import.meta.env.VITE_APP_BASE_API,
          // 超时
          timeout: 120000,
          withCredentials: true
      })
      
      // request拦截器
      service.interceptors.request.use(config => {
          // do something
          return config
      }, error => {
      
      })
      
      
      // 响应拦截器
      service.interceptors.response.use(res => {
              loadingInstance?.close()
              loadingInstance = null
              // 未设置状态码则默认成功状态
              const code = res.data.code || 200;
              // 获取错误信息
              const msg = errorCode[code] || res.data.msg || errorCode['default']
              if (code === 500) {
                  ElMessage({message: msg, type: 'error'})
                  return Promise.reject(new Error(msg))
              } else if (code === 401) {
                  return refreshFun(res.config);
              } else if (code === 601) {
                  ElMessage({message: msg, type: 'warning'})
                  return Promise.reject(new Error(msg))
              } else if (code == 400) {
                  // 需要用户confirm是否强制登陆
                  return Promise.resolve(res.data)
              } else if (code !== 200) {
                  ElNotification.error({title: msg})
                  return Promise.reject('error')
              } else {
                  return Promise.resolve(res.request.responseType === 'blob' ? res : res.data)
              }
          },
          error => {
              loadingInstance?.close()
              loadingInstance = null
              if (error.response.status == 401) {
                  return refreshFun(error.config);
              }
              let {message} = error;
              if (message == "Network Error") {
                  message = "后端接口连接异常";
              } else if (message.includes("timeout")) {
                  message = "系统接口请求超时";
              } else {
                  message = error.response.data ? error.response.data.msg : 'message'
              }
              ElMessagejs({message: message, type: 'error', duration: 5 * 1000})
              return Promise.reject(error)
          }
      )
      
      // 正在刷新标识,避免重复刷新
      let refreshing = false;
      // 请求等待队列
      let waitQueue = [];
      
      function refreshFun(config) {
          if (refreshing == false) {
              refreshing = true;
              return useUserStore().refreshToken().then(() => {
                  waitQueue.forEach(callback => callback()); // 已成功刷新token,队列中的所有请求重试
                  waitQueue = [];
                  refreshing = false;
                  return service(config)
              }).catch((err) => {
                  waitQueue = [];
                  refreshing = false;
                  if (err.response) {
                      if (err.response.status === 403) {
                          ElMessageBox.confirm('登录状态已过期(认证失败),您可以继续留在该页面,或者重新登录', '系统提示', {
                              confirmButtonText: '重新登录',
                              cancelButtonText: '取消',
                              type: 'warning'
                          }).then(() => {
                              useUserStore().logoutClear();
                              router.push(`/login`);
                          }).catch(() => {
      
                          });
                          return Promise.reject()
                      } else {
                          console.log('err:' + (err.response && err.response.data.msg) ? err.response.data.msg : err)
                      }
                  } else {
                      ElMessage({
                          message: err.message,
                          type: 'error',
                          duration: 5 * 1000
                      })
                  }
              })
          } else {
              // 正在刷新token,返回未执行resolve的Promise,刷新token执行回调
              return new Promise((resolve => {
                  waitQueue.push(() => {
                      resolve(service(config))
                  })
              }))
          }
      }

      总结

      以上为个人经验,希望能给大家一个参考,也希望大家多多支持编程客栈(www.devze.com)。

      0

      上一篇:

      下一篇:

      精彩评论

      暂无评论...
      验证码 换一张
      取 消

      最新开发

      开发排行榜