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