Next.js-Cookie鑑權+續期+退出登入

Awbeci發表於2023-05-11

前言

之前寫過關於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失效了,這個機率非常之小,所以也可以放心使用,但是還是有人覺得不行,肯定會報錯,是啊,就算真的發生也會報錯的,前端處理報錯退出當前頁面跳轉到登入頁面即可。

相關文章