身份 cookie 仅保存 token / user_id / has_login / id;昵称、头像、余额等资料存入加密 user-profile localStorage。客户端 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.ts plugin 完成。详见 渲染模式快速指南。

State(computed 派生,只读)

鉴权 state 由 tokenInfoCookie 派生,客户端 info 合并加密 localStorage 资料。

字段类型说明
infoRef<UserInfo | null>完整用户信息(user_id / nickname / vip / has_login / …)。null = 未登录
tokenRef<string>纯 token 字符串(不含 user_id)。未登录时为 ''
authTokenRef<string>请求用凭证:已登录 = ${token}_${user_id};设备登录 = token;未登录 = ''。framework request util 自动用此字段
isLoggedInRef<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 加密)无 TTLAccountInfo 对象存 localStorage 不进 cookie(密码不随每个 HTTP 请求飞)
channel_code / share_codeCookie(AES 加密 + secure)30 天渠道码 / 邀请码后端归因
  • 所有 cookie 默认带 secure: true(localhost / 127.0.0.1 自动豁免)+ sameSite: lax(CSRF 防护)。
  • AES 加密通过 framework useCookie / useLocal wrapper 透明完成,密钥 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.ts plugin 在 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 生命周期

相关

注意事项

  • 写状态方法一律 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 时序与字段归一,并把业务焊死在内部存储布局上。