身份 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(跨 tab 共享),供自建埋点通道 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),供 LoginForm 在用户取消"记住我"勾选时立即清 |
autoLogin() | () => Promise<boolean> | 自动登录编排器:根据 saved AccountInfo.remember 决定路径。remember=true 且有凭证 → 账密/二维码续命;否则降级 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(默认 true)/ 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 | 鉴权最小集。SSR 端可读 |
${deviceKey} | Cookie(AES 加密 + secure) | 10 年 | deviceId UUID | 设备指纹,logout() 不清,跨用户保留 |
${accountKey} | localStorage(AES 加密) | 无 TTL | AccountInfo 对象 | 存 localStorage 不进 cookie(密码不随每个 HTTP 请求飞) |
channel_code / share_code | Cookie(AES 加密 + secure) | 30 天 | 渠道码 / 邀请码 | 后端归因 |
- 所有 cookie 默认带
secure: true(localhost / 127.0.0.1 自动豁免)+sameSite: lax(CSRF 防护)。 - AES 加密通过 framework
useCookie/useLocalwrapper 透明完成,密钥runtimeConfig.public.cacheAesKey。 - ⚠️ 加密 ≠ 安全屏障。详见 客户端加密的安全边界。
- SSR 期
useCookie透明读 cookie → state 自动恢复;client 期 cookie 变更自动同步到 state。 - 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 时序与字段归一,并把业务焊死在内部存储布局上。