前言
之前寫過關於next.js鑑權操作cookie的文章,Next.js-cookie鑑權+續期1和Next.js-cookie鑑權+續期2,最近chatgpt非常火,於是我問它next.js鑑權最佳實踐,下面是它的回答:
- 設定合理的 cookie 有效期:合理的 cookie 有效期應該根據網站的特點來確定,通常建議將其設定為一定時間內(如 30 分鐘)。
- 定時重新整理 cookie 的有效期:可以透過伺服器端的 HTTP 攔截器來監測每個請求的 cookie 是否即將過期,當發現其即將過期時,立即重新設定一個新的 cookie,並在響應頭中返回給客戶端。
- 前端最佳化提示彈窗:透過前端 JavaScript 實現 cookie 彈窗過期提醒,將其結合到使用者體驗最佳化方案之中,提供清晰的提示,幫助使用者處理 cookie 續期或是否關閉要求。
- 風控,控制 cookie 的資料許可權:根據使用者的操作行為等風險因素,為 cookie 設定不同的許可權級別,限制 cookie 的資料許可權。
- 使用 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、設定成secure
的cookie
只能本地localhost
和https
協議才能使用
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失效了,這個機率非常之小,所以也可以放心使用,但是還是有人覺得不行,肯定會報錯,是啊,就算真的發生也會報錯的,前端處理報錯退出當前頁面跳轉到登入頁面即可。