乐趣区

关于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 共享的一些细节

退出移动版