1

前言

之前写过关于next.js鉴权操作cookie的文章,Next.js-cookie鉴权+续期1Next.js-cookie鉴权+续期2,最近chatgpt非常火,于是我问它next.js鉴权最佳实践,下面是它的回答:

  1. 设置合理的 cookie 有效期:合理的 cookie 有效期应该根据网站的特点来确定,通常建议将其设置为一定时间内(如 30 分钟)。
  2. 定时刷新 cookie 的有效期:可以通过服务器端的 HTTP 拦截器来监测每个请求的 cookie 是否即将过期,当发现其即将过期时,立即重新设置一个新的 cookie,并在响应头中返回给客户端。
  3. 前端优化提示弹窗:通过前端 JavaScript 实现 cookie 弹窗过期提醒,将其结合到用户体验优化方案之中,提供清晰的提示,帮助用户处理 cookie 续期或是否关闭要求。
  4. 风控,控制 cookie 的数据权限:根据用户的操作行为等风险因素,为 cookie 设置不同的权限级别,限制 cookie 的数据权限。
  5. 使用 HTTPS 协议:采用 HTTPS 协议,可保障传输过程中传输安全。 在实践中,续期策略需要根据不同网站的业务场景,以及用户的使用习惯,考虑相应的细节情况。

于是我又问,cookie轮询操作是不是影响服务器性能,于是它回答我:

在用户活跃期间,页面会一直更新并续期 cookie。为了检查 cookie 是否过期并续期它,可以在页面中使用 useEffect 监听 window 对象的 focus 事件,当用户回到页面时检查 cookie 是否过期。如果 cookie 快要过期,就续期它。

于是我又问,使用 useEffect 监听 window 对象的 focus 事件好还是轮询好,下面是它的回答:

使用 window.addEventListener("focus", handleFocus) 更好,因为它能够响应用户的交互行为,减少不必要的访问和资源消耗。
如果使用轮询,每隔固定的时间间隔就会向服务器发送请求,这可能会浪费用户的时间和网络带宽,对服务器也会造成额外的压力。而使用 window.addEventListener("focus", handleFocus) 到用户出现交互行为时才会进行操作,这是更高效和准确的方式。

于是,我就又把之前的鉴权推翻了,使用了chatgpt推荐的方式,分享给大家。

操作

前端操作

首先在布局页面监听用户的动作,然后调用验证cookie的操作,如果快要过期则返回以set-cookie的方式返回给前端浏览器中保存,否则不做处理,这样比轮询操作既简单又方便,又不会频繁发起请求消耗服务器性能。

layout.js

// 监听用户动作,如果页面被点击就请求cookie是否将要过期,如果是则返回新cookie,否则不做anything
  useEffect(() => {
    setMounted(true)
    // 判断是否是客户端
    if (process.browser && isLogin){
      window.addEventListener("focus", handleFocus);
      return () => {
        window.removeEventListener("focus", handleFocus);
      };
    }

  }, [])

  // 验证cookie是否将要过期,如果是返回新cookie写入到浏览器
  async function handleFocus(){
    const res = await dispatch(refreshCookie())
    if (res.payload.status === 40001){
      confirm({
        title: '登录已过期',
        icon: <ExclamationCircleFilled />,
        content: '您的登录已过期,请重新登录!',
        okText: '确定',
        cancelText: '取消',
        onOk() {
          // 重新登录
          location.href = '/login'
        },
        onCancel() {
            // 刷新当前页面
          location.reload()
        },
      });
    }
  }

我们把之前操作中的axiosInstance.interceptors.response.use(function (response)代码全部移除掉,只剩下下面的代码:

axios.js

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

export default axiosInstance;

这样所有页面每次在服务端执行getServerSideProps方法时,只需要传递cookie到axios的请求头中即可。
page.js

export const getServerSideProps = wrapper.getServerSideProps(store => async (ctx) => {
  axios.defaults.headers.cookie = ctx.req.headers.cookie || null
  // 判断请求头中是否有set-cookie,如果有,则保存并同步到浏览器中
  // if(axios.defaults.headers.setCookie){
  //   ctx.res.setHeader('set-cookie', axios.defaults.headers.setCookie)
  //   delete axios.defaults.headers.setCookie
  // }
  return {
    props: {
      
    }
  };
});

后台操作

首先是springgateway的代码,如下所示:

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    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();
    logger.info("request cookie2={}", com.alibaba.fastjson.JSONObject.toJSON(request.getCookies()));

    // 设置全局跟踪id
    if (isCorrelationIdPresent(headers)) {
        logger.debug("correlation-id found in tracking filter: {}. ", filterUtils.getCorrelationId(headers));
    } else {
        String correlationID = generateCorrelationId();
        exchange = filterUtils.setCorrelationId(exchange, correlationID);
        logger.debug("correlation-id generated in tracking filter: {}.", correlationID);
    }


    // 获取请求的URI
    String url = request.getPath().pathWithinApplication().value();
    logger.info("请求URL:" + url);
    // 这些前缀的url不需要验证cookie
    if (url.startsWith("/info") || url.startsWith("/websocket") || 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中保存cookie,格式:key: session_jti,value:xxxxxxx
        // 从redis中获取过期时间
        long sessionExpire = globalCache.getExpire(session);
        logger.info("redis key={} expire = {}", session, sessionExpire);
        if (sessionExpire > 1) {
            // 从redis中获取token信息
            Map<Object, Object> result = globalCache.hmget(session);
            String accessToken = result.get("access_token").toString();
            try {
                HashMap authinfo = getAuthenticationInfo(accessToken);
                ObjectMapper mapper = new ObjectMapper();
                String authinfoJson = mapper.writeValueAsString(authinfo);
                // 注意:这里保存的key: user,value:userinfo保存到请求头中供下游微服务获取,否则获取用户信息失败
                request.mutate().header(FilterUtils.USER, authinfoJson);
                // 这个token名存实亡了,要不要无所谓
                request.mutate().header(FilterUtils.AUTH_TOKEN, accessToken);
                return chain.filter(exchange);
            } catch (Exception ex) {
                logger.info("getAuthenticationName error={}", ex.getMessage());
                // 如果获取失败则返回给前端错误信息
                return getVoidMono(response);
            }
        }
    }
    // cookie不存在或redis中也没找到对应cookie的用户信息(说明是假的cookie)
    // 让cookie失效
    setCookie("", 0, response);
    // 说明redis中的token不存在或已经过期
    logger.info("session 不存在或已经过期");
    return getVoidMono(response);
}

还有一个就是监听focus事件调用的后台接口方法,如下所示:

  /**
     * 续期cookie过程
     * 1、cookie key重新生成,并设置到浏览器
     * 2、老的删除,创建新的redis key=xxx并保存token,时间和cookie时间相同
     *  注意:浏览器只发送key-name的cookie到后台,而发送不了对应的过期时间,我也不知道为什么!
     * @param request
     * @param response
     * @return
     */
    @GetMapping("/web/refresh")
    public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {

        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(SESSION_KEY)) {
                    logger.info("request cookie={}", cookie);
                    String oldCookieKey = cookie.getValue();
                    String newCookieKey = UUID.randomUUID().toString().replace("-", "");
                    // redis中保存cookie,格式:key: session_jti,value:xxxxxxx
                    // 从redis中获取过期时间
                    // 查询redis中是否有cookie对应的数据
                    long sessionExpire = globalCache.getExpire(oldCookieKey);
                    logger.info("redis.sessionExpire()={}", sessionExpire);
                    // 如果有,则延期redis中的cookie
                    // 新cookie:查看redis中是否小于10分钟,如果是,则重新生成新的30分钟的cookie给浏览器
                    if (sessionExpire > 1 && sessionExpire < COOKIE_EXPIRE_LT_TIME) {
                        logger.info("cookie快要过期了,我来续期一下");
                        // 获取redis中保存的用户信息
                        Map<Object, Object> result = globalCache.hmget(cookie.getValue());
                        logger.info("request redis auth info={}", JSONObject.toJSON(result));
                        if (result != null) {
                            //cookie未过期,继续使用
                            expireCookie(newCookieKey, COOKIE_EXPIRE_TIME, response);
                            expireRedis(oldCookieKey, newCookieKey, result);
                        }
                    }else{
                        logger.info("cookie没有过期");
                    }
                    return ResponseEntity.ok(new ResultSuccess<>(true));
                }
            }
        }
        return ResponseEntity.ok(new ResultSuccess<>(ResultStatus.AUTH_ERROR));
    }

    // 延期cookie
    private void expireRedis(String oldCookieKey, String newCookieKey, Map<Object, Object> result) {
        // redis设置该key的值立即过期
        //time要大于0 如果time小于等于0 将设置无限期
        globalCache.expire(oldCookieKey, 1);
        // 转化result
        Map<String, Object> newResult = (Map) result;
        // 保存到redis中
        globalCache.hmset(newCookieKey, newResult, COOKIE_EXPIRE_TIME);
    }

    // 延期cookie
    private void expireCookie(String cookieValue, Integer cookieTime, HttpServletResponse httpServletResponse) {
        ResponseCookie cookie = ResponseCookie.from(SESSION_KEY, cookieValue) // key & value
                .httpOnly(true)        // 禁止js读取
                .secure(true)        // 在http下也传输
                .domain(serviceConfig.getDomain())// 域名
                .path("/")            // path,过期用秒,不过期用天
                .maxAge(Duration.ofSeconds(cookieTime))
                .sameSite("Lax")    // 大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
                .build();
        httpServletResponse.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }

退出登录

之前两篇文章都忘了写了,这里补充一下退出操作吧,下面是具体的思路:
1、调用服务器端接口,接口中删除cookie,其实就是返回的set-cookie中时效为0
2、后台接口返回之后,浏览器中的cookie即可删除,这时页面跳转到登录页面即可

具体代码如下所示:

前端js代码:

// 只有服务器端才能清除httponly的cookie
await dispatch(logout())
// 清除完之后立马跳转到登录页面
location.href = '/login'

后台java代码:

    /**
     * 退出登录
     *
     * @param request
     * @param response
     */
    @PostMapping("/web/logout")
    public void refreshToken(HttpServletRequest request, HttpServletResponse response) {
        Cookie[] cookies = request.getCookies();

        if (cookies.length > 0) {
            // 遍历数组
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("session_jti")) {
                    String value = cookie.getValue();
                    logger.info("cookie session_jti={}", value);
                    if (StringUtils.hasLength(value)) {
                        // 从redis中删除
                        globalCache.del(value);
                        ResponseCookie clearCookie = ResponseCookie.from("session_jti", "") // key & value
                                .httpOnly(true)        // 禁止js读取
                                .secure(true)        // 在http下也传输
                                .domain(serviceConfig.getDomain())// 域名
                                .path("/")            // path
                                .maxAge(0)    // 1个小时候过期
                                .sameSite("None")    // 大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
                                .build();
                        // 设置Cookie到返回头Header中
                        response.setHeader(HttpHeaders.SET_COOKIE, clearCookie.toString());
                    }
                }
            }
        }
    }

这样就完成了Next.js的鉴权、cookie续期和退出的所有操作了!

注意

1、当客户端浏览器使用axios请求接口时,会自动把cookie带到后台
2、当客户端浏览器使用axios请求接口时,自动把后台返回的set-cookie保存到浏览器中
3、前端浏览器js不能操作httponly的相关cookie,只有服务端才行
4、设置成securecookie只能本地localhosthttps协议才能使用
5、在getServerSideProps方法中使用axios时,axios请求头中是不存在cookie的,所以需要将context中的cookie手动设置到axios的请求头中,如下:

axios.defaults.headers.cookie = ctx.req.headers.cookie || null

6、在getServerSideProps方法中使用axios后,保存在axios请求头中的set-cookie不会自动写入到浏览器中,需要取出来放到context中,如下:

ctx.res.setHeader('set-cookie', axios.defaults.headers.setCookie)

总结

1、之前的文章是在axiosInstance.interceptors.response.use(function (response)中拼接cookie,但是没有上面的方便,可能有的人会担心这个focus会不会重复调用接口影响性能?我可以放心跟大家讲,这个focus只有第一次才生效,当你切换到其它应用再回来了才重新调用。
2、这里页面刷新的时候调用getServerSideProps方法可能会有三种结果:

a、没有认证的cookie,
b、有认证的cookie,
c、处于有和没有之间。

a和b没啥好说的,c的情况比较特殊,比如getServerSideProps之中有三个接口,当执行第1个接口时平安无事,因为处于有效期内,当执行第2的接口时,发现认证的cookie失效了,这个概率非常之小,所以也可以放心使用,但是还是有人觉得不行,肯定会报错,是啊,就算真的发生也会报错的,前端处理报错退出当前页面跳转到登录页面即可。


Awbeci
3.1k 声望215 粉丝

Awbeci