成为全栈·Next.js 网站前台篇·C 端认证:内存令牌、HttpOnly Cookie 与会话代次
access token 放在内存可以缩短暴露时间,refresh token 放进 HttpOnly Cookie 可以避开 JavaScript 读取。但真正困难的部分,是多个请求同时 401、刷新时退出登录,以及旧账号请求晚到这些竞态。
前言
公开内容站加入会员功能以后,认证经常被简化成三步:登录拿 token、请求加 Authorization、401 就刷新。单条请求这样写很容易,页面一打开同时请求资料、收藏和通知时,系统却会进入并发世界。
如果三个请求同时 401,它们可能同时拿同一个 refresh token 去刷新。后端采用刷新令牌旋转时,第一个请求成功并换发新令牌,后两个继续使用旧令牌,随后失败并把用户踢出登录。
更隐蔽的是,用户在刷新请求未完成时点击退出;旧刷新结果晚到后,又把已经结束的会话“复活”。所以这篇不只讨论 token 存哪里,而是把会话当成有生命周期和代次的并发状态。
两种令牌承担不同风险
| 凭证 | 当前存放位置 | 生命周期 | JavaScript 能否读取 | 主要用途 |
|---|---|---|---|---|
| access token | Zustand 内存 | 较短 | 可以 | API Authorization |
| refresh token | HttpOnly Cookie | 较长 | 不可以 | 静默恢复与换发 access token |
access token 不进入 localStorage,刷新页面后自然丢失。浏览器仍会把同源 HttpOnly Cookie 自动带给刷新接口,应用启动时有机会恢复会话。
这不是“绝对安全”的宣言。XSS 仍可能在当前页面生命周期内以用户身份发请求;Cookie 仍需要 SameSite、Secure、Path 和跨站写入保护。存储方案只是减少某些攻击面,不能代替完整防御。
Zustand 只保存浏览器当前需要的会话状态
interfaceAuthState{generation:numberaccessToken:string|nulluser:PublicUser|nullbootStatus:'idle'|'booting'|'ready'setSession:(auth:AuthResult)=>voidclear:()=>void}exportconstuseAuthStore=create<AuthState>((set)=>({generation:0,accessToken:null,user:null,bootStatus:'idle',setSession:(auth)=>set({accessToken:auth.accessToken,user:auth.user,bootStatus:'ready',}),clear:()=>set((state)=>({generation:state.generation+1,accessToken:null,user:null,bootStatus:'ready',})),}))bootStatus区分“还没尝试恢复”和“已经确认是游客”,避免会员页面在启动瞬间误跳登录。generation则是本篇最重要的字段:每次清除会话都递增,旧异步任务即使完成,也不能再写回新生命周期。
启动恢复需要去重,也要允许游客失败
letbootInFlight:Promise<boolean>|null=nullexportconstbootstrapSession=():Promise<boolean>=>{if(bootInFlight)returnbootInFlightconststore=useAuthStore.getState()if(store.bootStatus==='ready'){returnPromise.resolve(Boolean(store.accessToken))}constgeneration=store.generation store.setBootStatus('booting')bootInFlight=refreshOnce().then(()=>true).catch(()=>{if(generation===useAuthStore.getState().generation){useAuthStore.getState().clear()}returnfalse}).finally(()=>{useAuthStore.getState().setBootStatus('ready')bootInFlight=null})returnbootInFlight}首次访问的游客没有 refresh Cookie,恢复失败是正常分支,不应弹出“登录失败”。Promise 去重还能兼容开发环境 StrictMode 或多个组件同时等待启动状态。
所有并发 401 必须共用一次刷新
letrefreshInFlight:Promise<string>|null=nullconstrefreshOnce=():Promise<string>=>{if(!refreshInFlight){refreshInFlight=performRefresh().finally(()=>{refreshInFlight=null})}returnrefreshInFlight}真正刷新时记录会话代次:
constperformRefresh=async():Promise<string>=>{constgeneration=useAuthStore.getState().generationconstauth=awaitrawRequest<AuthResult>('/auth/refresh',{method:'POST',body:{},skipAuth:true,skipAuthRedirect:true,skipRefresh:true,},{isRefreshCall:true})if(generation!==useAuthStore.getState().generation){thrownewError('会话已结束,请重新登录')}useAuthStore.getState().setSession(auth)returnauth.accessToken}等待中的业务请求拿到同一个新 access token 后各自重放。刷新接口自身设置skipRefresh,重放请求带retried标记,防止 401 递归刷新形成死循环。
旧请求不能覆盖新账号
请求发出时同时记录代次和令牌:
constgeneration=useAuthStore.getState().generationconstsentToken=skipAuth?null:useAuthStore.getState().accessTokenconstresponse=awaitfetch(url,{headers,cache:'no-store'})if(!skipAuth&&generation!==useAuthStore.getState().generation){thrownewError('会话已切换,请重试')}还要处理另一种顺序:请求 A 带旧 access token 发出,请求 B 已经完成刷新并写入新 token,随后请求 A 才返回 401。此时无需再次刷新,直接用内存中的新 token 重放:
if(response.status===401&&!flags.retried&&sentToken!==useAuthStore.getState().accessToken&&useAuthStore.getState().accessToken){returnrawRequest(path,options,{...flags,retried:true})}这条分支减少无意义刷新,也避免用已经旋转失效的 Cookie重复换发。
401 不是一种错误,而是一组业务语义
| 401 原因 | 是否尝试刷新 | 页面行为 |
|---|---|---|
| access token 过期 | 是 | 刷新成功后重放一次 |
| refresh token 过期或撤销 | 否或刷新失败 | 清会话并转登录 |
| 账号被禁用 | 否 | 清会话并说明禁用状态 |
| 登录凭据错误 | 否 | 留在登录表单显示字段错误 |
| 刷新接口自身 401 | 否 | 防止递归刷新 |
请求层根据业务错误码决定isRefreshable和shouldForceLogout,不能看到 HTTP 401 就无条件刷新。否则密码错误也可能触发一次毫无意义的刷新请求。
身份切换时只清理私有查询
项目曾在身份变化时调用queryClient.clear(),结果把公开文章流也清掉。现在根据 query key 保留公开内容:
const unsubscribe = useAuthStore.subscribe((next, previous) => { if (next.user?.id !== previous.user?.id) { queryClient.removeQueries({ predicate: (query) => query.queryKey[0] !== 'public-feed', }) } })更大的项目可以建立清楚的 public/private key 工厂,不必只靠第一个字符串判断。核心原则是:会话改变必须清除用户数据,但不能误删与身份无关的公开内容。
客户端守卫不等于权限边界
会员 layout 可以在bootStatus === 'ready'且没有用户时跳转登录,这解决的是用户体验:避免未登录者看到一闪而过的会员页面。
真正的授权仍由后端完成。攻击者可以绕过 React 页面直接请求 API;只有后端对 token、角色和资源归属的校验才能阻止越权。前端内存令牌与守卫都不是后端权限检查的替代物。
用时间顺序验证会话竞态
场景 A:三个私有请求同时 401 预期:只出现一次 /auth/refresh,三个请求各重放一次 场景 B:refresh 在飞时点击退出 预期:generation 递增,旧 refresh 成功结果不能恢复会话 场景 C:A 账号资料请求在飞时登录 B 预期:A 的响应被代次检查拒绝,不能写进 B 的页面 场景 D:旧 token 请求晚到 401,新 token 已存在 预期:直接使用新 token 重放,不再次刷新还要检查 token 不出现在 localStorage、sessionStorage、URL 和错误日志里;刷新响应 JSON 中也不应把 refresh token交给客户端 JavaScript。
适用边界
这套架构适合公开内容为主、会员交互为辅的前台。若整个应用都在受保护服务端页面中运行,可以考虑服务端会话与 BFF 代持访问令牌,但仍要处理刷新旋转和并发。
若系统采用一次性会话、WebAuthn 或第三方身份平台,具体令牌形态会不同。不过“旧生命周期的异步结果不得写回新会话”仍是通用并发原则。
小结
token 放在哪里只是认证设计的第一层。完整的会话还需要启动恢复、刷新 Promise 去重、单次重放、业务错误分流,以及阻止旧请求跨越退出和账号切换。
generation看起来只是一个整数,它真正表达的是:会话已经进入下一代。旧世界里完成的异步任务,没有资格修改新世界的用户状态。
延伸阅读
- 服务端组件与客户端组件
- 同源 BFF 代理
- 会员中心:资料、密码与个人数据
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer