身分 cookie 僅保存
token / user_id / has_login / id;暱稱、頭像、餘額等資料存入加密user-profilelocalStorage。客戶端info合併兩部分,SSR 的info僅含鑑權欄位。setInfo完整替換資料,所有登入與自動重新登入均在寫入前拆分;setToken保留其他資料。expireLogin與logout同時清理資料。客戶端首次讀取舊完整 cookie 時遷移;若 nginx 已拒絕請求,需要先清理該站身分 cookie,頁面腳本無法繞過入口 400。
userStore
framework 內建的使用者身份 Pinia store,composable 名是 useUserStore(不是 useUserInfoStore,原始碼位於 src/runtime/store/user.ts)。
登入憑證以原子物件 tokenInfo 形式持久到 cookie:重新整理 / SSR 都能自動恢復,「token 在、user_id 就在」絕不分離。
基本用法
<script setup lang="ts">
const userStore = useUserStore()
// 狀態都是 computed,模板裡直接用
console.log(userStore.isLoggedIn, userStore.info?.nickname)
// 業務操作
async function onLogin() {
await userStore.loginByUsername(form.username, form.password)
await navigateTo(route.query.redirect as string || '/')
}
async function onLogout() {
await userStore.logout() // 先撤銷伺服器端會話再清本地;不 await 時本地清理仍同步生效
navigateTo('/login')
}
</script>
framework 不在 SSR 期主動呼叫
userStore.autoLogin()。已登入使用者的 cookie 由 useCookie 自然透傳,request工具自動注入authToken;新訪客的裝置登入在 client hydrate 後由auth.client.tsplugin 完成。詳見 算繪模式快速指南。
State(computed 派生,唯讀)
鑑權 state 由 tokenInfoCookie 派生,客戶端 info 合併加密 localStorage 資料。
| 欄位 | 類型 | 說明 |
|---|---|---|
info | Ref<UserInfo | null> | 完整使用者資訊(user_id / nickname / vip / has_login / …)。null = 未登入 |
token | Ref<string> | 純 token 字串(不含 user_id)。未登入時為 '' |
authToken | Ref<string> | 請求用憑證:已登入 = ${token}_${user_id};裝置登入 = token;未登入 = ''。framework request util 自動用此欄位 |
isLoggedIn | Ref<boolean> | 是否為真實使用者登入(has_login === 'y')。裝置登入不算 |
裝置登入使用者(has_login = 'n')能呼叫匿名介面、做行為埋點,但
isLoggedIn仍為 false。
Actions
| 方法 | 簽名 | 說明 |
|---|---|---|
getDeviceId() | () => string | 取裝置 ID。首次呼叫時用 framework randomUuid() 產生 + 寫 cookie(10 年);非 secure context(LAN HTTP)自動降級,不裸依賴 crypto.randomUUID |
getSessionId() | () => string | 取埋點工作階段 ID(framework#415)。距上次呼叫不足 30 分鐘沿用,否則產生新 ID;每次呼叫刷新活動時間。存 localStorage(跨分頁共享),供自建埋點通道 context.session_id 使用 |
setInfo(data) | (data: UserInfo) => void | 完整替換使用者資訊,鑑權欄位寫 cookie,其他資料寫加密 localStorage。目前已有 token 而傳入資料缺 token 時 throw;只補充或修改資料時合併寫入:setInfo({ ...userStore.info, ...data }) |
setToken(value) | (value: string) => void | 僅更新 tokenInfo.token 欄位,其它欄位保留 |
setAccount(data) | (data: AccountInfo) => void | 儲存登入憑證用於自動登入。data.remember === false 時清空 accountLocal |
getAccountInfo() | () => AccountInfo | null | 讀取儲存的登入憑證 |
clearAccount() | () => void | 單獨清空 AccountInfo(不動 tokenInfo) |
autoLogin() | () => Promise<boolean> | 自動登入編排器:根據 saved AccountInfo.remember 決定路徑;無憑證 → 降級 loginByDevice() |
loginByDevice() | () => Promise<Record> | 裝置登入:無憑證使用者的匿名身份(靠 deviceId 識別,不寫 AccountInfo),供 autoLogin 降級 / 業務顯式呼叫 |
loginByUsername(username, password, options?) | (string, string, LoginOptions?) => Promise<Record> | 帳號密碼登入。options.remember(預設 true)決定是否持久化 AccountInfo。options.extra 透傳後端 |
loginByQrcode(code, options?) | (string, LoginOptions?) => Promise<Record> | 二維碼登入。options.remember / options.extra 同上 |
register(loginName, password, options?) | (string, string, LoginOptions?) => Promise<Record> | 自助註冊(POST /login/register,後端回傳與 /login/username 同形狀回應)。成功即進登入態(複用 setInfo/setAccount 鏈),options.remember / options.extra 同上;失敗(重名等)拋 Error 且登入態不變 |
expireLogin() | () => void | 登入窮盡最小清理(fw#302):2002 靜默重登重試仍失敗時由 request 插件呼叫,清 tokenInfo 与用户资料(isLoggedIn 歸 false);不動 deviceId / accountLocal / 渠道碼 / IDB。業務一般不直接呼叫——監聽 swift-web:auth-expired hook 做引導即可 |
logout() | () => Promise<void> | 先撤銷伺服器端會話(POST /login/logout,失敗靜默、不阻斷本地清理),再清空 tokenInfo / channel_code / share_code cookie + accountLocal / user-profile(localStorage)+ IDB 快取。保留 deviceId。本地清理在同步段完成,不 await 也立即生效 |
UserInfo 類型
interface UserInfo {
user_id?: number | string
username?: string
nickname?: string
headico?: string // 頭像 URL
token?: string
has_login?: 'y' | 'n' // 'y' 真實使用者登入;'n' 裝置登入
vip?: number // VIP 等級,0 = 普通
vip_expire?: string
balance?: number
websocket?: string // WebSocket 連線位址
[key: string]: unknown // 後端可擴展任意欄位
}
持久化儲存
按「SSR 是否需讀」+「是否需隨每個請求發」+「敏感度」分配位置:
| Key | 媒介 | 壽命 | 內容 |
|---|---|---|---|
${tokenKey} | Cookie(AES 加密 + secure) | 30 天 | token / user_id / has_login / id |
${deviceKey} | Cookie(AES 加密 + secure) | 10 年 | deviceId UUID |
${accountKey} | localStorage(AES 加密) | 無 TTL | AccountInfo 物件(存 localStorage 不進 cookie) |
channel_code / share_code | Cookie(AES 加密 + secure) | 30 天 | 渠道碼 / 邀請碼 |
- 所有 cookie 預設帶
secure: true+sameSite: lax。 - AES 加密透過 framework
useCookie/useLocalwrapper 透明完成。 - ⚠️ 加密 ≠ 安全屏障。詳見 客戶端加密的安全邊界。
- AccountInfo 在 localStorage 中無 TTL,「記住我」=記到 logout 或使用者取消勾選。
跨標籤頁同步
cookie 是天然跨標籤頁(同源同 path)。一個標籤頁 logout() 清 cookie → 其他標籤頁的 useCookie ref 在下次存取時拿到空值 → state 同步為未登入。
但已載入的頁面不會自動 re-render —— 業務側如需「即時」同步可監聽 storage 事件或用 BroadcastChannel。framework 不內建這層邏輯(避免冗餘訂閱)。
身分建立與安全模型
device + auth 身分完全 client-only 建立(設計決策見 framework#44,安全加固見 framework#45):
- SSR 期 framework 永不呼叫寫狀態方法(
autoLogin/loginByDevice/getDeviceId/setAccount/setInfo等)——任何會寫 useCookie ref 的 mutation 都不在 server 期觸發,保證 Pinia payload 不被烤進 ISR 快取;這些方法頂部有assertClientOnly(),server 呼叫直接 throw - 裝置登入由 client 端
auth.client.tsplugin 在 hydrate 後建立;已登入使用者的 token 透過 cookie 自然透傳到 SSR;業務呼叫request()自動等 auth-ready - 密碼類憑證不進 cookie:AccountInfo 存加密 localStorage,DevTools Cookies 面板不可見、不隨請求飛
remember語義:loginByUsername/loginByQrcode的options.remember(預設 true)決定是否持久化 AccountInfo;remember: false不保存;使用者中途取消勾選用clearAccount()立即清- 核心 cookie 全部 AES 加密 + 預設
secure: true(localhost 自動豁免)+sameSite: lax
實戰配方
<!-- 配方 1:LoginForm 透傳「記住我」勾選(STORE-002,必須顯式透傳) -->
<script setup lang="ts">
const isRemember = ref(true)
async function onSubmit() {
await useUserStore().loginByUsername(form.username, form.password, {
remember: isRemember.value,
})
}
</script>
// 配方 2:等登入鏈路就緒再判斷登入態(UAR-001:auth-ready ≠ 登入成功)
const userStore = useUserStore()
onMounted(async () => {
await useAuthReady()
if (!userStore.isLoggedIn) return useLoginCheck()
console.log(userStore.info?.nickname)
})
// 配方 3:業務擴充 store —— 包裝而非改寫(升級 framework 零衝突)
export const useAppUserStore = defineStore('app-user', () => {
const base = useUserStore()
const { info, isLoggedIn } = storeToRefs(base)
const vipLevel = computed(() => (info.value?.vip as number) ?? 0)
return { info, isLoggedIn, vipLevel, logout: base.logout }
})
// 配方 4:SSR 讀寫邊界(STORE-001)
const userStore = useUserStore()
const logged = userStore.isLoggedIn // ✅ 唯讀成員 SSR 可用
// userStore.getDeviceId() // ❌ setup 同步路徑呼叫寫方法 → server throw
onMounted(() => userStore.getDeviceId()) // ✅ 寫方法挪 client 生命週期
相關
useAuthReady— 等待 client 端 device + auth 建立完成useLoginCheck— 登入驗證器(頁面跳轉 / 彈窗模式)useLoginBy— 智慧登入方式選擇(裝置能力 + UA 判斷)- auth ModuleOptions — 路由守衛 + client auth init 開關
注意事項
- 寫狀態方法一律 client-only,SSR 期呼叫直接 throw(Rule STORE-001)——
getDeviceId/getSessionId/setInfo/setToken/setAccount/clearAccount/loginByUsername/register/loginByQrcode/loginByDevice/autoLogin/expireLogin/logout頂部都有assertClientOnly()守衛。根因是 device id + token 完全在用戶端建立,SSR 期呼叫會寫 Set-Cookie 並烤進 Pinia payload,被 ISR 快取廣播給所有訪客 = 不同訪客拿到同一身分(安全級事故)。挪到onMounted/ event handler /<ClientOnly>內。 - 唯讀成員 SSR 可用 ——
info/token/authToken/isLoggedIn/getAccountInfo不受上條限制。 - 「記住我」必須顯式透傳(Rule STORE-002)——
loginByUsername/loginByQrcode的remember預設true;LoginForm 不傳使用者的勾選狀態,則使用者取消勾選後仍會被持久化(帳號 / 加密密碼進 localStorage)。寫法:loginByUsername(u, p, { remember: isRemember.value })。 - 別繞開 store 直讀核心 cookie(已有 eslint 規則
swift-web/no-direct-cookie-in-app攔截)—— 注意兩條直讀路徑壞在不同地方:document.cookie.match(...)拿到的是 AES 密文;而useCookie(...)拿到的其實是明文(framework 已同名覆蓋 Nuxt 內建的 useCookie,自動拼cachePrefix+ 透明解密),它壞在別處 —— 真實 key 是runtimeConfig.public.tokenKey(如tt),寫useCookie('token')讀的是另一個不存在的 cookie、靜默返回null;就算 key 用對,也繞開了 store 的寫守衛、auth-ready 時序與欄位歸一,並把業務焊死在內部儲存布局上。