关于next.js:Nextjs用户认证和刷新token方案

前言

最近在应用Next.js的时候发现用户认证和刷新token时候跟之前单页面利用SPA的token认证和刷新token计划有所出入,实现起来也更简单,于是本人参考B站掘金思否简书的SSR网站折腾了一段时间终于解决了这个问题,分享给大家做参考,如果你们感觉文中有不妥的中央也心愿不吝指出。

单页面利用TOKEN认证和刷新计划

咱们在用SPA做后盾管理系统认证受权的时候,始终采纳的是jwt token计划,这套计划简略而高效,流程如下:

1、用户登录胜利后盾生成jwt token并返回给前端保留到localstorage

2、前端接口通过axios申请头上携带Authorization: token 传递给后盾做认证

3、后盾通过前端传递过去的token验证是否通过

4、如果token过期,能够通过axios的拦截器拦挡401状态码并带上refresh_token从后盾获取新token并保留到localstorage中

上面是axios拦截器的应用,参考

axios.interceptors.response.use(
  error => {
    /*
    *当响应码为 401 时,尝试刷新令牌。
   */
    if (status == 401) {
      return axios.post('/api/login/refresh', {}, {
        headers: {
          'Authorization': 'Bearer ' + getRefreshToken()
        }
      }).then(async response => {
        const data = response.data.data
        setToken(data.token)
        setRefreshToken(data.refreshToken)
        error.response.config.headers['Authorization'] = 'Bearer ' + data.token
        return await axios(error.response.config).then(res => res.data)
      }).catch(error => {
        //清理token
        store.dispatch('user/resetToken')
        this.router.push('/login')
        return Promise.reject(error)
      })
    }
  }
)

然而在SSR做互联网我的项目时,这套计划就显得不是十分敌对,有如下几个问题须要解决:

1、jwt token在到期之前不能被动生效,所以如果你想把一个人拉黑或者踢出,你是没方法的(当然也是有方法的,你能够做个黑名单,然而token不会被动生效是客观事实的)

2、SSR后盾不能从localstorage中拿到jwt token

3、jwt token过期接口报错并返回401状态码,通过axios拦截器去申请新token过程中会有卡顿景象,用户体验不太好

我本人通过网上查找的博客文章这篇文章网友给出了好几种解决方案,通过本人思考之后,下面的问题都能够失去解决,办法如下所示:

解决问题1:jwt token + redis
token存储到redis中,当要拉黑和踢人的时候,从redis中删除即可。

解决问题2:应用cookie保留jwt token
前后端通过cookie存储和传递数据

解决问题3:接口申请时被动刷新token
每次申请的时候主动刷新token

B站、掘金的认证和刷新token计划

因为我应用的是Next.js的SSR计划,所以我在想大厂是如何实现认证和刷新token的,于是带着问题我光顾了B站、掘金、思否和简书这样的SSR网站,咱们来剖析他们如何实现的。

B站

首先咱们登录b站,而后剖析前端代码,发现前端是自行搭建的一套架构,语言是vue.js,有趣味的能够看看这篇文章哔哩哔哩(B站)的前端之路

咱们再来看看它的登录数据存储,通过一直的尝试,当咱们把SESSIONDATA删除之后,就退出了登录,所以它的登录id应该就是这个SESSIONDATA,而且它的过期工夫将近一年。

接着,我再通过每天的一直刷新看看这个SESSIONDATA多久更新一次,而后发现是3天更新一次。

掘金

咱们登录掘金之后,再来看看它的源码,发现它应用的是NUXT.JS

咱们再来看看它的登录数据存储,通过一直的尝试,当咱们把sessionid删除之后,就退出了登录,所以它的登录id应该就是这个sessionid,而且它的过期工夫是一年。

接着,我再通过每天的一直刷新看看这个sessionid多久更新一次,而后发现是14天更新一次。

我的认证和刷新token计划

通过上面对b站和掘金网站的剖析,上面就来实现我本人的认证和刷新token计划,如下:

1、用户登录胜利后盾生成jwt token(设置成永不过期)

  • 1.1、同时生成一个uuid,应用key=uuid,value=token保留到redis(设置redis的ttl过期工夫为7天)
  • 1.2、通过set-cookie把uuid设置到客户端浏览器的cookie中(过期工夫为一年)

2、前端通过axios申请头上携带uuid的cookie传递给后盾做认证

3、后盾判断cookie中的uuid

  • 3.1:如果redis中查问到key=uuid并且redis的ttl工夫小于3天,则生成new_uuid,并保留到redis中(工夫也是7天),老的uuid的redis要删除,生成的new_uuid通过set-cookie保留到客户端浏览器中,工夫也为一年
  • 3.2:如果redis中查问到到key=uuid并且ttl大于3天,则重置key=uuid的redis的ttl工夫为7天(相当于续期)
  • 3.3:如果redis中没有查问到key=uuid的数据,则放行

代码实现

前端应用的是next.js+redux-toolkit,后盾我应用的是springboot+springsecurity+ oauth2 + springcloud gateway

上面咱们通过代码来实现计划1

代码如下所示:

@PostMapping("/web/login")
    public ResponseEntity<?> login(@RequestBody AuthRequest authRequest, HttpServletResponse response) {
        String userName = authRequest.getUserName();
        String password = authRequest.getPassword();
        if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
            return ResponseEntity.ok(new ResultInfo<>(ResultStatus.DATA_EMPTY));
        }
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("username", userName);
        body.add("password", password);
        body.add("client_id", serviceConfig.getClientId());
        body.add("client_secret", serviceConfig.getClientSecret());
        body.add("grant_type", "password");
        body.add("scope", "all");
        //调用auth服务
        try {
            Date now = new Date();
            // result 对象外面是返回的jwt token信息,access_token,refresh_token,jti,token_type
            Object result = authFeignClient.postAccessToken(body);
            Map entity = (Map)result;
            String key = UUID.randomUUID().toString().replace("-", "");
            String access_token = entity.get("access_token").toString();
            String token_type = entity.get("token_type").toString();
            if(StringUtils.hasLength(access_token)){
                HashMap<String, Object> obj = new HashMap<>();
                obj.put("user_name", userName);
                obj.put("access_token", access_token);
                obj.put("token_type", token_type);
                // 保留到redis中,过期工夫设置成7天,7天之后主动删除
                globalCache.hmset(key, obj, 60 * 60 * 24 * 7);

                ResponseCookie cookie = ResponseCookie.from("session_jti", key) // key & value
                        .httpOnly(true)        // 禁止js读取
                        .secure(true)        // 在http下也传输
                        .domain(serviceConfig.getDomain())// 域名
                        .path("/")            // path
                        .maxAge(Duration.ofDays(365))    // 1年后过期
                        .sameSite("Lax")    // 大多数状况也是不发送第三方 Cookie,然而导航到指标网址的 Get 申请除外
                        .build();
                // 设置Cookie到返回头Header中
                response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
            }

            User user = userService.getUserByName(userName);
            Map<String, Object> userInfo = new HashMap<>();
            userInfo.put("name", userName);
            userInfo.put("avatar", user.getAvatar());
            userInfo.put("email", user.getEmail());
            return ResponseEntity.ok(new ResultSuccess<>(userInfo));
        } catch (Exception ex) {
            return ResponseEntity.ok(new ResultInfo<>(ResultStatus.USER_AUTH_ERROR));
        }
    }

上面咱们通过代码来实现计划2

代码如下所示:

next.js页面代码(截取局部):

export const getServerSideProps = wrapper.getServerSideProps(store => async (ctx) => {
  // 1、获取cookie并保留到axios申请头cookie中
  axios.defaults.headers.cookie = ctx.req.headers.cookie || null
  await store.dispatch(getSessionUser())

  const {isLogin, me} = store.getState().auth;
  store.dispatch(setHeader({
    isTransparent: true
  }))
  if (isLogin) {
   await store.dispatch(getUserData());
  }
  // 2、判断申请头中是否有set-cookie,如果有,则保留并同步到浏览器中
  if(axios.defaults.headers.setCookie){
    ctx.res.setHeader('set-cookie', axios.defaults.headers.setCookie)
    delete axios.defaults.headers.setCookie
  }
  return {
    props: {
      isLogin
    }
  };
});

axios配置代码:

const axiosInstance = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_API_HOST}`,
  withCredentials: true,
});

// 增加响应拦截器
axiosInstance.interceptors.response.use(function (response) {
  console.log('response=', response)

  // 指标:合并setCookie
  // A、将response.headers['set-cookie']合并到axios.defaults.headers.setCookie中
  // B、将axios.defaults.headers.setCookie合并到axios.defaults.headers.cookie中

  // 留神:set-cookie格局和cookie格局区别
  /** axios.defaults.headers.setCookie和response.headers['set-cookie']格局如下
   *
   *  axios.defaults.headers.setCookie = [
   *    'name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None'
   *  ]
   *
   * **/

  /** axios.defaults.headers.cookie 格局如下
   *
   *  axios.defaults.headers.cookie = name=Justin;age=18;sex=男
   *
   * **/
  // A1、判断是否是服务端,并且返回申请头中有set-cookie
  if (typeof window === 'undefined' && response.headers['set-cookie']) {
    // A2、判断axios.defaults.headers.setCookie是否是数组
    // A2.1、如果是,则将response.headers['set-cookie']合并到axios.defaults.headers.setCookie
    // 留神:axios.defaults.headers.setCookie默认是undefined,而response.headers['set-cookie']默认是数组
    if (Array.isArray(axiosInstance.defaults.headers.setCookie)) {

      // A2.1.1、将后盾返回的set-cookie字符串和axios.defaults.headers.setCookie转化成对象数组
      // 留神:response.headers['set-cookie']可能有多个,它是一个数组

      /** setCookie.parse(response.headers['set-cookie'])和setCookie.parse(axios.defaults.headers.setCookie)格局如下
       *
       setCookie.parse(response.headers['set-cookie']) = [
       {
            name: 'userName',
            value: 'Justin',
            path: '/',
            maxAge: 365,
            expires: 2022-08-16T07:56:46.000Z,
            secure: true,
            httpOnly: true,
            sameSite: 'None'
          }
       ]
       * **/
      const _resSetCookie = setCookie.parse(response.headers['set-cookie'])
      const _axiosSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
      // A2.1.2、利用reduce,合并_resSetCookie和_axiosSetCookie对象到result中(有则替换,无则新增)
      const result = _resSetCookie.reduce((arr1, arr2) => {
        // arr1第一次进来是等于初始化化值:_axiosSetCookie
        // arr2顺次是_resSetCookie中的对象
        let isFlag = false
        arr1.forEach(item => {
          if (item.name === arr2.name) {
            isFlag = true
            item = Object.assign(item, arr2)
          }
        })
        if (!isFlag) {
          arr1.push(arr2)
        }
        // 返回后果值arr1,作为reduce下一次的数据
        return arr1
      }, _axiosSetCookie)

      let newSetCookie = []
      result.forEach(item => {
        // 将cookie对象转换成cookie字符串
        // newSetCookie = ['name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None']
        newSetCookie.push(cookie.serialize(item.name, item.value, item))
      })
      // A2.1.3、合并完之后,赋值给axios.defaults.headers.setCookie
      axiosInstance.defaults.headers.setCookie = newSetCookie
    } else {
      // A2.2、如果否,则将response.headers['set-cookie']间接赋值
      axiosInstance.defaults.headers.setCookie = response.headers['set-cookie']
    }


    // B1、因为axios.defaults.headers.cookie不是最新的,所以要同步这样后续的申请的cookie都是最新的了
    // B1.1、将axios.defaults.headers.setCookie转化成key:value对象数组
    const _parseSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
    // B1.2、将axios.defaults.headers.cookie字符串转化成key:value对象
    /** cookie.parse(axiosInstance.defaults.headers.cookie)格局如下
     *
     *  {
     *    userName: Justin,
     *    age: 18,
     *    sex: 男
     *  }
     *
     * **/
    const _parseCookie = cookie.parse(axiosInstance.defaults.headers.cookie)

    // B1.3、将axios.defaults.headers.setCookie赋值给axios.defaults.headers.cookie(有则替换,无则新增)
    _parseSetCookie.forEach(cookie => {
      _parseCookie[cookie.name] = cookie.value
    })
    // B1.4、将赋值后的key:value对象转换成key=value数组
    // 转换成格局为:_resultCookie = ["userName=Justin", "age=19", "sex=男"]
    let _resultCookie = []
    for (const key in _parseCookie) {
      _resultCookie.push(cookie.serialize(key, _parseCookie[key]))
    }
    // B1.5、将key=value的cookie数组转换成key=value;字符串赋值给axiosInstance.defaults.headers.cookie
    // 转换成格局为:axios.defaults.headers.cookie = "userName=Justin;age=19;sex=男"
    axiosInstance.defaults.headers.cookie = _resultCookie.join(';')
  }
  return response;
}, function (error) {
  console.log('error=', error)
  // 超出 2xx 范畴的状态码都会触发该函数。
  // 对响应谬误做点什么
  if ([401, 403, 405, 500].includes(error.response.status)){
    location.reload()
  }

  return Promise.reject(error);
});
export default axiosInstance;

置信大家对这两段代码有点疑难,大家能够参考我之前的一篇文章来看就明确了Next.js 服务端操作Cookie。

上面咱们通过代码来实现计划3

代码如下所示:

    private static final int SESSION_EXPIRE = 6 * 24 * 60 * 60;
    private static final String SESSION_KEY = "uuid";


    @Autowired
    private IGlobalCache globalCache;

    @Autowired
    ServiceConfig serviceConfig;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        Date now = new Date();
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        HttpHeaders headers = request.getHeaders();
        Flux<DataBuffer> body = request.getBody();
        MultiValueMap<String, HttpCookie> cookies = request.getCookies();
        MultiValueMap<String, String> queryParams = request.getQueryParams();
        // 获取申请的URI
        String url = request.getPath().pathWithinApplication().value();
        // 放行登录、刷新token和登出
        if (url.startsWith("/web/login") || url.startsWith("/web/refreshToken") || url.startsWith("/web/logout")) {
            // 放行
            return chain.filter(exchange);
        }
        logger.info("cookie ={}", cookies);
        HttpCookie cookieSession = cookies.getFirst(SESSION_KEY);

        if (cookieSession != null) {
            logger.info("session id ={}", cookieSession.getValue());
            String session = cookieSession.getValue();
            // 从redis中获取过期工夫
            long sessionExpire = globalCache.getExpire(session);
            if (sessionExpire > 1) {
                // 从redis中获取token信息
                Map<Object, Object> result = globalCache.hmget(session);
                String accessToken = "";
                String tokenType = "";
                for (Map.Entry<Object, Object> vo : result.entrySet()) {
                    if ("access_token".equals(vo.getKey())) {
                        accessToken = (String) vo.getValue();
                    }
                    if ("token_type".equals(vo.getKey())) {
                        tokenType = (String) vo.getValue();
                    }
                }

                // 获取剩余时间(秒数)
                // 判断剩余时间是不是小于3天
                // 如果是,则从新获取token,否则续期7天
                if (sessionExpire < SESSION_EXPIRE / 2) {
                    // 延期token
                    expireCookie(session, result, response);
                } else {
                    // redis续期6天
                    globalCache.expire(session, SESSION_EXPIRE);
                }
                String token = tokenType + " " + accessToken;
                // 放行之前,将令牌封装到头文件中(这一步是为了不便AUTH2校验令牌)
                request.mutate().header(FilterUtils.AUTH_TOKEN, token);
            } else {
                // 让cookie生效
                setCookie("", 0, response);
                // 阐明redis中的token不存在或曾经过期
                logger.info("session 不存在或曾经过期");
                response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
                return response.setComplete();
            }
        }
        return chain.filter(exchange);
    }

    // 延期cookie
    private void expireCookie(String session, Map<Object, Object> result, ServerHttpResponse serverHttpResponse) {
        String newKey = UUID.randomUUID().toString().replace("-", "");
        // redis设置该key的值立刻过期
        //time要大于0 如果time小于等于0 将设置无限期
        globalCache.expire(session, 1);
        // 转化result
        Map<String, Object> newResult = (Map) result;
        // 保留到redis中
        globalCache.hmset(newKey, newResult, SESSION_EXPIRE);
        setCookie(newKey, 365, serverHttpResponse);
    }

    // 设置cookie
    private void setCookie(String cookieValue, Integer cookieTime, ServerHttpResponse serverHttpResponse) {
        ResponseCookie cookie = ResponseCookie.from(SESSION_KEY, cookieValue) // key & value
                .httpOnly(true)        // 禁止js读取
                .secure(true)        // 在http下也传输
                .domain(serviceConfig.getDomain())// 域名
                .path("/")            // path,过期用秒,不过期用天
                .maxAge(cookieTime == 0 ? Duration.ofSeconds(cookieTime) : Duration.ofDays(cookieTime))    // 1年后过期
                .sameSite("Lax")    // 大多数状况也是不发送第三方 Cookie,然而导航到指标网址的 Get 申请除外
                .build();
        serverHttpResponse.addCookie(cookie);
    }

至此实现实现了计划外围代码,大家如果有问题欢送发问。

总结

1、cookie跨域问题能够参考这篇文章前端应该晓得的Cookie常识
2、next.js 合并set-cookie能够参考这篇文章Next.js 服务端操作Cookie
3、保险起见能够把key:uuid,value:token值再保留到mongodb或者mysql中,这样就算redis崩了,也能从备选库中查问到数据

援用

JWT生成token及过期和主动续期
「springcloud 2021 系列」Spring Cloud Gateway + OAuth2 + JWT 实现对立认证与鉴权
Token 刷新并发解决解决方案
Spring Cloud Gateway — cookie增加批改
spring cloud gateway基于jwt实现用户鉴权+GatewayFilter自定义拦截器(残缺demo)
SpringBoot设置和获取Cookie
redis实现session共享的一些细节

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理