ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

成为全栈·Next.js 网站前台篇·C 端认证:内存令牌、HttpOnly Cookie 与会话代次

成为全栈·Next.js 网站前台篇·C 端认证:内存令牌、HttpOnly Cookie 与会话代次 成为全栈·Next.js 网站前台篇·C 端认证内存令牌、HttpOnly Cookie 与会话代次access token 放在内存可以缩短暴露时间refresh token 放进 HttpOnly Cookie 可以避开 JavaScript 读取。但真正困难的部分是多个请求同时 401、刷新时退出登录以及旧账号请求晚到这些竞态。前言公开内容站加入会员功能以后认证经常被简化成三步登录拿 token、请求加 Authorization、401 就刷新。单条请求这样写很容易页面一打开同时请求资料、收藏和通知时系统却会进入并发世界。如果三个请求同时 401它们可能同时拿同一个 refresh token 去刷新。后端采用刷新令牌旋转时第一个请求成功并换发新令牌后两个继续使用旧令牌随后失败并把用户踢出登录。更隐蔽的是用户在刷新请求未完成时点击退出旧刷新结果晚到后又把已经结束的会话“复活”。所以这篇不只讨论 token 存哪里而是把会话当成有生命周期和代次的并发状态。两种令牌承担不同风险凭证当前存放位置生命周期JavaScript 能否读取主要用途access tokenZustand 内存较短可以API Authorizationrefresh tokenHttpOnly Cookie较长不可以静默恢复与换发 access tokenaccess token 不进入 localStorage刷新页面后自然丢失。浏览器仍会把同源 HttpOnly Cookie 自动带给刷新接口应用启动时有机会恢复会话。这不是“绝对安全”的宣言。XSS 仍可能在当前页面生命周期内以用户身份发请求Cookie 仍需要 SameSite、Secure、Path 和跨站写入保护。存储方案只是减少某些攻击面不能代替完整防御。Zustand 只保存浏览器当前需要的会话状态interfaceAuthState{generation:numberaccessToken:string|nulluser:PublicUser|nullbootStatus:idle|booting|readysetSession:(auth:AuthResult)voidclear:()void}exportconstuseAuthStorecreateAuthState((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.generation1,accessToken:null,user:null,bootStatus:ready,})),}))bootStatus区分“还没尝试恢复”和“已经确认是游客”避免会员页面在启动瞬间误跳登录。generation则是本篇最重要的字段每次清除会话都递增旧异步任务即使完成也不能再写回新生命周期。启动恢复需要去重也要允许游客失败letbootInFlight:Promiseboolean|nullnullexportconstbootstrapSession():Promiseboolean{if(bootInFlight)returnbootInFlightconststoreuseAuthStore.getState()if(store.bootStatusready){returnPromise.resolve(Boolean(store.accessToken))}constgenerationstore.generation store.setBootStatus(booting)bootInFlightrefreshOnce().then(()true).catch((){if(generationuseAuthStore.getState().generation){useAuthStore.getState().clear()}returnfalse}).finally((){useAuthStore.getState().setBootStatus(ready)bootInFlightnull})returnbootInFlight}首次访问的游客没有 refresh Cookie恢复失败是正常分支不应弹出“登录失败”。Promise 去重还能兼容开发环境 StrictMode 或多个组件同时等待启动状态。所有并发 401 必须共用一次刷新letrefreshInFlight:Promisestring|nullnullconstrefreshOnce():Promisestring{if(!refreshInFlight){refreshInFlightperformRefresh().finally((){refreshInFlightnull})}returnrefreshInFlight}真正刷新时记录会话代次constperformRefreshasync():Promisestring{constgenerationuseAuthStore.getState().generationconstauthawaitrawRequestAuthResult(/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 递归刷新形成死循环。旧请求不能覆盖新账号请求发出时同时记录代次和令牌constgenerationuseAuthStore.getState().generationconstsentTokenskipAuth?null:useAuthStore.getState().accessTokenconstresponseawaitfetch(url,{headers,cache:no-store})if(!skipAuthgeneration!useAuthStore.getState().generation){thrownewError(会话已切换请重试)}还要处理另一种顺序请求 A 带旧 access token 发出请求 B 已经完成刷新并写入新 token随后请求 A 才返回 401。此时无需再次刷新直接用内存中的新 token 重放if(response.status401!flags.retriedsentToken!useAuthStore.getState().accessTokenuseAuthStore.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三个请求各重放一次 场景 Brefresh 在飞时点击退出 预期generation 递增旧 refresh 成功结果不能恢复会话 场景 CA 账号资料请求在飞时登录 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
返回列表