身份 cookie 仅保存
token / user_id / has_login / id;昵称、头像、余额等资料存入加密user-profilelocalStorage。客户端info合并两部分,SSR 的info仅含鉴权字段。setInfo是完整替换,所有登录和自动重登均在写入前拆分;setToken保留其他资料。expireLogin和logout同时清理资料。客户端首次读取旧完整 cookie 会迁移,已被 nginx 拒绝的旧请求需要先清理该站身份 cookie,页面脚本无法绕过入口 400。
自动登录 + Token 过期重登链路
给"想看整张图"的人。如果你只想知道某个页面该用什么策略,去 渲染模式快速指南;只想看
useAuthReadyAPI 怎么用,去 useAuthReady。本文档描述 framework v3 鉴权设计(device + token 完全 client-only 建立,framework#44)。
一句话总结
身份建立完全 client-only,token 过期由 request 拦截层自动续期。
server 端永不主动登录、永不写 device id —— 避免任何用户态烤进 ISR 缓存。这是 framework 渲染策略安全的根基。
完整时序图
图中的
swiftWebFramework.auth是「运行时」key:plugin 实际读取的是runtimeConfig.public.swiftWebFramework.auth。你在swift.config.json/nuxt.config.ts配置时写的是swiftConfig.auth——framework 在构建期把它注入到运行时的runtimeConfig.public.swiftWebFramework.auth。两者指同一开关,分属配置期(swiftConfig.auth)与运行时(swiftWebFramework.auth)两层 key;本时序图描述的是 hydration 后的运行时自检,故用后者。
关掉设备登录:
swiftConfig.auth.deviceLogin: false时,上图两处POST /login/device都不再发生——无凭证或凭证失效即以游客态(无 token)结束,autoLogin()返回false;账密 / 二维码登录与凭证续登不受影响。门只在autoLogin()一处,系统信息兜底与 401 / 2002 重登同样遵守(fw#412)。
三种渲染模式行为对照
| 环节 | SSR | ISR (prerender) | CSR (ssr: false) |
|---|---|---|---|
system.server.ts(公开 /system/info;渠道码种植在 system.client.ts,fw#212) | ✅ 每请求 | ✅ 一次(缓存) | ❌ |
auth.client.ts plugin(getDeviceId + auto login) | ❌(仅指 server 渲染期) | ❌(仅指 server 渲染期) | ✅ hydration 后一次 |
request() 内部 await useAuthReady() | ❌(no-op) | ❌(no-op) | ✅ |
token 过期 → autoLogin → 重试 | server 端不触发(server 拿 cookie token;过期则按未登录返回) | 同 SSR | ✅ 拦截 + 重试 |
login.global 中间件守卫 guardedPaths | ✅(无 user state,未登录跳 /login) | 不参与 | ✅ |
关键点:auth.client.ts 是 client plugin,三种渲染模式的页面 hydration 后都会跑(表中 ❌ 仅指 server 渲染期不执行)。三种模式从 loginByDevice 视角完全等价 —— device id + token 都只在 client hydration 后才建立。SSR 期 server 端拿 cookie 里已有的 token 透传给后端(request.ts 直连后端),但永不主动发起登录。
限制与取舍(明牌)
⚠️ 首次访客在 SSR 页面会有"首屏匿名"窗口
未登录首次访客打开一个 ssr: true 页面时:
- server 端 cookie 没有 tokenInfo / deviceId
- server 端
request()透传空 token → 后端按完全匿名响应 - SSR HTML 渲染出来是匿名版本(看不到昵称 / VIP 徽标 / 个性化)
- Hydration →
auth.client.ts跑 → 设备登录拿到 device token - 用户态片段(昵称 / 徽标)在 hydration 后才突然出现 → 视觉闪烁
这是 ISR-主推架构的固有取舍 —— 用"server 永不触碰 user state"换"所有公开页面都能安全 ISR 缓存"。完整论证见 framework#44。
缓解 idiom(业务侧必须采用):
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const userStore = useUserStore()
const userReady = ref(false)
onMounted(async () => {
await useAuthReady()
userReady.value = true
})
</script>
<template>
<article>
<SectionPublic />
<ClientOnly>
<SectionUserBadges v-if="userReady" :info="userStore.info" />
<SkeletonBadges v-else />
</ClientOnly>
</article>
</template>
这样用户态片段从一开始就是 skeleton/空,登录完成后才填真值 —— 视觉上是"加载完成"而不是"突然变身"。
⚠️ token 过期自动重试只在 client 端发生
SSR 期 server 端发现 token 过期(errorCode 2002),不会触发 onRelogin —— 因为 server 端没办法重新建立设备身份(重新登录会写 Set-Cookie 进响应头,被 ISR 缓存广播)。
后果:server 端的 useFetch 拿过期 token 请求 → 后端按未登录返回 → SSR HTML 当作匿名渲染。Hydration 后 client 端的同一个 useFetch 重新拉取时才会触发 token 续期 + 重试。
结论:用户感觉像是"刷新页面后登录态丢了一秒钟又回来了"。这是 v3 设计的另一个固有取舍。
业务侧禁忌(治理层 + lint 层 + runtime 层三道防线)
server 端误调身份建立 API 会污染 ISR 缓存 —— 不同访客拿到同一个 token / deviceId(安全级事故)。framework 在三层都设了护栏:
1. Runtime 守卫
getDeviceId / autoLogin / loginByUsername / register / loginByQrcode / loginByDevice / expireLogin / logout 等会写状态的方法在 import.meta.server 时直接 throw,让违规调用立刻爆炸而不是静默污染缓存。这是最后一道防线。
2. ESLint 规则
@swift-web/framework/eslint 计划提供 swift-web/no-server-user-mutation 规则(尚未实现),扫描 <script setup> 同步路径中的 userStore.autoLogin/.setInfo/... 调用。
3. standards 治理规则
standards/rules/ssr-safety.md 列出 "client-only 身份建立" 作为硬约束,sync 到所有 applier 项目的 review 规则。
业务侧记牢:
- ❌
<script setup>同步路径里调useUserStore().autoLogin() / getDeviceId() / setInfo() / loginByUsername() - ❌
useAsyncData的 callback 里调上述方法(callback 也在 SSR 期执行) - ✅
onMounted/ event handler /<ClientOnly>内部 —— 这些位置才是身份建立的合法时机
详细反模式见 渲染模式快速指南 §反模式速查。
登录穷尽:2002 重试仍失效时
client 端 request() 收到 errorCode 2002 会静默重登再重试一次(见 Q3);重试仍是 2002 即「登录穷尽」——token 连重登都救不回来(如后端整轮换密钥)。framework ≥ 0.107.0 的穷尽行为(fw#302):
- 先清用户态:
expireLogin()清 tokenInfo 与用户资料(isLoggedIn响应式归 false,页面不再以已登录身份渲染);不动 deviceId(设备身份存续)、记住的账密(下次刷新 autoLogin 仍可续命)、渠道码。 - 后广播:nuxtApp hook
swift-web:auth-expired(payload{ reason: 'relogin-exhausted' }),每个失效期至多一次、重新登录成功后复位。监听者回调里看到的已是登出态。 - 照旧抛
ApiError(2002):调用方 try/catch 语义不变。
业务侧零改动即拿到「不再虚报登录态」(守卫在下次导航归位);要即时引导(弹登录框 / 跳转),挂个监听:
// app/plugins/auth-expired.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('swift-web:auth-expired', () => {
useLoginDialog().open() // 或 navigateTo('/login')
})
})
SSR 期的穷尽不清态、不广播(保持纯 throw)——SSR 期动 cookie 会被 ISR 缓存广播给所有访客(见上文业务侧禁忌),且这个 throw 本身是「防空态烙进缓存」护栏的组成部分。
常见疑问
Q1:auth: false 时整条链路怎么走?
auth.client.ts 早返回,但仍调 _resolveAuthReady()。业务 request() 内部的 await useAuthReady() 不会卡住,按匿名请求正常发出。适合 docs 站等无 backend 项目。详见 auth ModuleOptions。
Q2:业务侧需要感知"自动登录失败"吗?
默认无感(设计权衡:避免 login 失败导致业务永久卡死)。auth.client.ts 用 try / finally 保证哪怕登录抛错也调 _resolveAuthReady() —— 业务请求会以匿名状态发出,后端按未登录处理。
如果业务需要明确知道"是匿名状态在请求",自己读 useUserStore().isLoggedIn(hydration 后才有意义)即可。
Q3:SPA 内 token 过期会自动重登吗?
会。机制不在 auth.client.ts(那是只跑一次的 plugin),而在 request.ts 拦截层 —— 看到 errorCode === 2002 就 await onRelogin() 后带 _retry=true 重试一次。业务侧零感知。
Q4:为什么 framework 内部 login 不走 request() 而走 frameworkInternalRequest()?
避免 deadlock:request() 内部 await useAuthReady();而 useAuthReady() 等的就是 login 完成。如果 login 也走 request(),永远等不到 resolve。详见 useAuthReady - 与 request 内部的协作机制。
Q5:deviceId 是怎么产生的?后端怎么识别同一台设备?
客户端用 framework randomUuid() 生成(secure context 走 crypto.randomUUID,LAN HTTP 等非 secure 环境降级到 getRandomValues 手搓 UUID v4,防 undefined 致 500),写入 cookie(10 年有效)。下次访问读 cookie 复用。换浏览器 / 隐身模式 / 清 cookie = 新 deviceId。
后端不依赖 IP / UA 这种弱标识 —— 完全以 cookie 里的 deviceId 为准。
相关文档
- useAuthReady — composable API 细节 + 业务用法
- userInfo Store — 登录态 / token / deviceId 数据结构
- 路由守卫 —
guardedPaths/guestPaths配置 - 请求系统 —
request()API + 响应解包 + 错误处理 - 渲染模式快速指南 — 业务页面 → 渲染策略 决策矩阵 + 反模式
- auth ModuleOptions —
auth: false整站关闭 - 源码:
framework/src/runtime/plugins/auth.client.ts+utils/request.ts+store/user.ts