Next.js用户认证和刷新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)
      })
    }
  }
)

Next.js用户认证和刷新token方案_第1张图片

Next.js用户认证和刷新token方案_第2张图片

但是在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站)的前端之路
Next.js用户认证和刷新token方案_第3张图片

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

Next.js用户认证和刷新token方案_第4张图片

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

掘金

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

Next.js用户认证和刷新token方案_第5张图片

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

Next.js用户认证和刷新token方案_第6张图片

接着,我再通过每天的不断刷新看看这个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 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 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 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 filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        Date now = new Date();
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        HttpHeaders headers = request.getHeaders();
        Flux body = request.getBody();
        MultiValueMap cookies = request.getCookies();
        MultiValueMap 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 result = globalCache.hmget(session);
                String accessToken = "";
                String tokenType = "";
                for (Map.Entry 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 result, ServerHttpResponse serverHttpResponse) {
        String newKey = UUID.randomUUID().toString().replace("-", "");
        // redis设置该key的值立即过期
        //time要大于0 如果time小于等于0 将设置无限期
        globalCache.expire(session, 1);
        // 转化result
        Map 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共享的一些细节

你可能感兴趣的:(next.jsssrjwt)