Stories

Detail Return Return

Next.js-Cookie鑑權+續期+退出登錄 - Stories Detail

前言

之前寫過關於next.js鑑權操作cookie的文章,Next.js-cookie鑑權+續期1和Next.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失效了,這個概率非常之小,所以也可以放心使用,但是還是有人覺得不行,肯定會報錯,是啊,就算真的發生也會報錯的,前端處理報錯退出當前頁面跳轉到登錄頁面即可。

user avatar mannayang Avatar xiaoniuhululu Avatar u_13529088 Avatar seazhan Avatar lenglingx Avatar u_15702012 Avatar chuanghongdengdeqingwa_eoxet2 Avatar yizhidanshendetielian Avatar gvison Avatar shenchendexiaoyanyao Avatar kanshouji Avatar javadog Avatar
Favorites 22 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.