身分 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