身分 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 為準。

相關文件