身份 cookie 仅保存 token / user_id / has_login / id;昵称、头像、余额等资料存入加密 user-profile localStorage。客户端 info 合并两部分,SSR 的 info 仅含鉴权字段。setInfo 是完整替换,所有登录和自动重登均在写入前拆分;setToken 保留其他资料。expireLogin 和 logout 同时清理资料。客户端首次读取旧完整 cookie 会迁移,已被 nginx 拒绝的旧请求需要先清理该站身份 cookie,页面脚本无法绕过入口 400。

自动登录 + Token 过期重登链路

给"想看整张图"的人。如果你只想知道某个页面该用什么策略,去 渲染模式快速指南;只想看 useAuthReady API 怎么用,去 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)。

三种渲染模式行为对照

环节SSRISR (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 页面时:

  1. server 端 cookie 没有 tokenInfo / deviceId
  2. server 端 request() 透传空 token → 后端按完全匿名响应
  3. SSR HTML 渲染出来是匿名版本(看不到昵称 / VIP 徽标 / 个性化)
  4. Hydration → auth.client.ts 跑 → 设备登录拿到 device token
  5. 用户态片段(昵称 / 徽标)在 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):

  1. 先清用户态:expireLogin() 清 tokenInfo 与用户资料(isLoggedIn 响应式归 false,页面不再以已登录身份渲染);不动 deviceId(设备身份存续)、记住的账密(下次刷新 autoLogin 仍可续命)、渠道码。
  2. 后广播:nuxtApp hook swift-web:auth-expired(payload { reason: 'relogin-exhausted' }),每个失效期至多一次、重新登录成功后复位。监听者回调里看到的已是登出态。
  3. 照旧抛 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 为准。

相关文档