GitHub

09 — 账号切换(多账号 / Account Switching)

状态:后端 + OP 账号选择器已实现,契约稳定,下游可接入。已上线:会话袋数据层、 /oauth/authorizeprompt=select_account|none + login_hint/auth/sessions (+ /switch/logout/logout-all)、管理员 step-up;OP 账号中心(apps/web)与 wiki 的切换器已接入。待接入:forum / moyu 的切换器 UI(照 §3.6 实现即可)+ prompt=none 焦点对齐(§3.2,后续阶段)。本文是下游(kungal / moyu / wiki)接入 「多账号 + 切换」的跨服务契约;IdP 内部实现(数据模型、决策依据)见 infra 仓 docs/auth/02-account-switching-design.md。 ⚠️ 生产首次部署需对 kun_galgame_infrago run ./cmd/migrate(加 sessions.browser_id 等列;部署不自动跑迁移)。

让用户像 Gmail/微软那样同时登录多个账号并一键切换。本文档读者 = 下游前端/后端开发; 讲「你要怎么调、能依赖什么保证」,不讲 IdP 内部表结构。


1. 模型(先理解这三句)

  1. IdP 持有「会话袋」:一个浏览器里登录的 N 个账号,全部由 OP(oauth.kungal.com)服务端持有,靠一个 httpOnly 的浏览器锚点 cookie 串起来。下游不持有多账号的 refresh token。
  2. 每个下游 app 同一时刻只持有「当前账号」的令牌
  3. 「切换」= 下游重新走一次到 OP 的授权码重定向prompt=select_account)。因为 OP 的浏览器 cookie 已经认识袋子里所有账号,所以切换不需要重新输入密码(管理员账号除外,见 §6)。

这是 accounts.google.com 的模型:会话归 OP,各产品跳过去选账号。因为我们的站点 跨顶级域(kungal.com ↔ moyu.moe),切换必须走重定向,绝不能跨域共享 cookie。

2. 关键基元(OIDC 标准参数,OP 已/将支持)

参数 作用 备注
prompt=select_account 让 OP 渲染账号选择器(袋子里 ≥1 个账号时) 新增
prompt=none 静默查询「当前是谁 / 我的会话还在吗」,OP 不渲染任何 UI;不能静默完成时回错误 新增
prompt=login 强制重新认证(管理员 step-up / 登出后防静默登回) 已存在,见 07-logout.md
错误 account_selection_required prompt=none 时用户登录了多个账号但没选 → 回退到选择器 新增
错误 login_required / consent_required / interaction_required prompt=none 无法静默完成 部分已存在

state + PKCE 在每次重定向都必须带(见 §7)。

3. 下游接入

3.1 触发账号选择器(切换 / 添加账号)

顶层跳转到 OP 授权端点,带上 prompt=select_account

GET https://oauth.kungal.com/api/v1/oauth/authorize
    ?client_id=<your_client_id>
    &redirect_uri=<your_registered_callback>
    &response_type=code
    &code_challenge=<pkce>&code_challenge_method=S256
    &state=<one-time, user-agent-bound>
    &prompt=select_account
  • OP 读浏览器锚点 cookie → 列出袋子里的账号(头像/昵称/邮箱)+「使用其他账号登录」。
  • 用户选账号 B → OP 把 B 设为活跃 → 若 B 是管理员,先强制重登(§6) → 下发授权码。
  • 你的回调用授权码换B 的新令牌,替换当前令牌。除 step-up 外无需输入凭据

想直接跳到某个已知账号(跳过选择器),可带 login_hint=<email>;OP 在无歧义时可不渲染 UI。

3.2 「全局活跃账号」对齐(本系统决策:global per browser)

活跃账号的唯一真源是 OP。下游靠向 OP 静默询问来对齐:

  • 页面加载 / 标签页获得焦点 / 路由切换时,下游做一次 prompt=none 静默检查。
  • 若 OP 的活跃账号 ≠ 本 app 当前账号 → 静默重新授权为活跃账号 → 替换令牌。
  • 诚实的限制(跨顶级域固有):后台标签页不会瞬时切换,它会在重新获得焦点/刷新时对齐。这是跨 TLD 能做到的最好「全局」。
    • 同 TLD 例外oauth.kungal.comwww.kungal.com 同属 kungal.com,可让锚点 cookie Domain=.kungal.com → kungal.com 家族内瞬时全局;只有 moyu.moe 走「焦点对齐」。

3.3 会话袋 API(仅同站 app,如账号中心 oauth.kungal.com/profile

这些 JSON 端点读 OP 域上的 Lax 锚点 cookie,因此只对与 OP 同站(kungal.com 家族: oauth.kungal.com 自身、wiki.kungal.comwww.kungal.com)的前端可用——同站下游若要 跨子域调用,还需 OP 为该 origin 放行 CORS(credentials。跨 TLD 的 moyu.moe 用不了SameSite cookie 不跨站 fetch 发送)→ 一律走 §3.1 重定向 + §3.6 本地缓存。

⚠️ 坑(同站接入必看)switch / logout 会返回业务性 40110016 step-up、 10005 不在袋中、10001 非成员)。别让前端「遇 401 就刷新令牌 / 跳登录」的全局拦截器 吞掉它们——这几个调用要绕过全局 401 处理、自己读响应 code 分支(否则 step-up 会被 误判成会话失效而把用户登出)。参考实现:apps/web useAccountSwitch.ts 用裸 $fetch 而非全局封装。

方法 路径 作用 响应(data
GET /api/v1/auth/sessions 列出本浏览器袋子里的账号 { items: [{ sub, name, email, avatar, avatar_image_hash?, roles, active, last_used_at }] }roles 供选择器显示角色徽标 + 管理员「需重新登录」提示)
POST /api/v1/auth/sessions/switch 把某账号设为活跃(同站内即时全局) { user, access_token }(即登录响应体;并轮换 refresh_token cookie。前端可直接更新缓存 + 用新 access_token)
POST /api/v1/auth/sessions/logout 登出此账号(撤销该会话) null(成功;目标不在袋中 → 404 / 10005)
POST /api/v1/auth/sessions/logout-all 登出全部(撤销袋内所有会话 + 清锚点 cookie) null

请求体:switch / logout{ sub }(账号的 user uuid)。鉴权 = 用户 JWT(BearerAuthorization header)——非 cookie 鉴权,天然免 CSRF,无需额外 CSRF 令牌。调用者(Bearer 身份)必须是该浏览器袋子的成员,否则 401(防 confused-deputy:仅凭锚点 cookie 不足以操作袋子)。

3.4 登出语义(本系统决策:基于撤销 / revocation-based)

关键:单次「退出登录」只应登出当前账号,不要清空整个袋子。 OIDC 规范本身倾向「按 会话登出」(用 sid 指明登出哪一个会话),没有规定「登出 = 登出所有账号」;Google 网页版的「登出即登出全部」是其产品取舍(且广受诟病,移动端就是逐个登出)。多账号最佳实践 = 同时提供**「退出当前账号」+「退出全部账号」**两个动作。

  • 退出当前账号POST /auth/sessions/logout {sub}):OP 硬删除该账号的会话(删了就刷不动 = 撤销),从袋子移除;其他账号不受影响。若删的是当前活跃账号,OP 顺带清 refresh_token cookie。推荐下游 UX:删除后落到袋内剩余的某个账号(仍保持登录其它账号,类 Gmail 移动端),袋空了才回登录页——不要一登出就把人踢回登录页/清掉所有账号。参考实现(apps/web 账号中心切换器):先 switch 到剩余的某个非管理员账号(这样调用者仍是袋成员 → 过 §3.3 的 confused-deputy 校验)再 logout 旧账号。
  • 退出全部账号POST /auth/sessions/logout-all):撤销袋内全部会话 + 清锚点 cookie。仅在用户显式选择「退出全部」时调用。
  • 传播机制 = 撤销 + 短 access token TTL(不用 back-channel / iframe):access token 寿命 ~10–15 分钟;任意 app 刷新时若会话已撤销则刷新失败 → 一个 TTL 内登出。想更快就调短 TTL,这是唯一旋钮。
    • 这样选是因为:下游是 SPA,没有服务端 RP 会话可供 back-channel logout 关联 sid;front-channel iframe 又依赖跨站 cookie(脆弱)。撤销式最简单、坑最少、可日后再叠加即时传播而无需重构。
  • 与 RP 发起登出(07-logout.md)的区别(别混用):上面的 logout / logout-all同站、OP 侧的会话袋管理(账号中心用)。跨 TLD 下游要登出自己这个 app 的会话,走 RP 发起登出(顶层跳 OP 登出入口,见 07);要切到别的账号走 §3.1 重定向 / §3.6 切换器。

3.5 管理员 step-up(本系统决策:切入管理员账号需重新认证)

  • 当选中的账号持 admin / ren 角色,切换会被拒(/auth/sessions/switch 返回 10016; 重定向流则由 OP 强制 prompt=login)→ 必须重新认证后才激活并下发令牌。
  • OP 记录 auth_time(已落库,为 max_age / 管理端点的「最近认证时间」校验预留;当前 仅切入 admin/ren 强制重登已生效,过旧 auth_time 的细粒度校验留待后续)。
  • 下游无需特殊处理——照常走 §3.1 重定向即可,OP 自动插入重登;走 JSON switch 的同站前端 收到 10016 时跳 /oauth/authorize?prompt=login&login_hint=<sub> 重认证(见 §3.6)。

3.6 账号选择器 UX(推荐下游统一实现)

理想交互:点头像 → 弹出菜单 → 「切换账号」项(桌面 hover / 手机点击)→ 二级菜单列出可切换账号 + 「添加新账号」。本设计支持它,机制如下,各站 UI 应统一这么做:

二级菜单的账号列表 = 本 app 的「已知账号」本地缓存(localStorage),不依赖跨站读 OP 袋子:

  • 缓存项 = { sub, name, avatar, avatar_image_hash?, email, roles? }(够渲染头像 / 邮箱 / 角色徽标),不含任何令牌。每次本 app 成功登录/切换到某账号后,从 GET /auth/me 取身份写入缓存。
  • 二级菜单直接渲染这个本地列表——跨 TLD 也能显示(因为不需要 OP 的 cookie)。
  • 点某账号 → 顶层跳转 …/oauth/authorize?prompt=select_account&login_hint=<sub|email>&state=…&code_challenge=…。顶层导航会带上 OP 的 Lax 锚点 cookie,OP 凭袋子静默切到该账号 → 回调换令牌。若 OP 袋子里已无该账号(在别处登出了)→ 优雅回退到登录。
  • 「添加新账号」→ 顶层跳转 …/oauth/authorize?prompt=login&…(或选择器里的「使用其他账号」)。

同站 app(kungal.com 家族)可选增强:直接 GET /auth/sessions(同站 cookie 会发送)实时拉取准确的袋子列表来覆盖本地缓存——列表永远最新。

诚实的限制(跨 TLD,如 moyu):本地缓存可能滞后真实袋子——在 forum 新加的账号 C,moyu 的二级菜单要等你在 moyu 上经 OP 切过一次才出现;在别处登出的账号点切换会回退到登录。OP 袋子始终是真源,切换永远以它为准,「添加新账号」永远可用 → 体验可用且一致,只是跨 TLD 的列表是「尽力而准」。

用各站自己的菜单/下拉组件实现嵌套(桌面 hover、移动端点击展开)。纯前端,不影响以上数据契约。参考实现(可直接照搬到 forum/moyu):apps/wiki 的 components/auth/AccountMenu.vue(嵌套菜单 + 角色徽标 + 「切换需重新登录」提示)+ composables/useKnownAccounts.ts(localStorage 缓存,SSR 安全)+ composables/useOAuthLogin.ts(带 prompt/login_hint 的授权重定向)。后端要求/oauth/authorize 接受 login_hintGET /auth/me 返回身份(均已有)。

4. 安全要求(下游 必须,依据 RFC 9700 / RFC 6819)

  • [ ] 每次切换/添加/对齐重定向都带一次性、绑定 user-agent 的 state + PKCE(SPA 是 public client)。防止伪造回调把受害者静默切到攻击者账号
  • [ ] 回调/返回 URL 必须在 OP 白名单内(复用 07-logout.mdpost-logout-redirect 白名单机制);不得跳转到任意 query 参数 URL(开放重定向会泄露授权码/令牌)。
  • [ ] 当前账号永远从令牌(sub)推导,绝不信任前端传的「账号 id」;每个写操作归属于令牌的 sub
  • [ ] access token 应 aud 限定到本 app 的资源服务器(moyu 的令牌不能拿去打 kungal 的 API)。
  • [ ] UI 上醒目展示当前账号(头像+昵称在 header),防止后台标签页 stale 导致「以为是 A 实际是 B」。

5. 错误码(实现已落地;详见 04-tokens-and-errors.md 认证段)

后端错误统一走 { code, message }(HTTP status + 业务 code 同时返回,下游两者都要看)。 切换/登出的业务错误复用认证段(10xxx),不另开 18xxx 段。

场景 HTTP code
需要选择账号(prompt=none 多账号未选,前端 OP 页判定) 重定向回下游 error=account_selection_required
切换/登出目标不在袋中(不存在/已撤销) 404 10005(ErrAuthUserNotFound)
切换目标需 step-up(管理员/ren,未重认证) 401 10016(ErrAuthStepUpRequired)→ 前端跳 prompt=login
调用者非该袋成员(confused-deputy 防护) 401 10001(ErrAuthUnauthorized)

6. 下游耦合点(重命名/重构时务必同步本节)

下游会硬编码以下事实:

  1. OP 授权端点接受 prompt=select_account / prompt=none / prompt=login
  2. 「全局活跃账号」语义 = 焦点对齐(非后台标签页即时),下游 UI 须照此设计预期。
  3. 登出传播 = 撤销 + 短 TTL(不是即时跨 app 登出)。
  4. 同站才有会话袋 JSON API;跨 TLD 只有重定向流。

7. 实施阶段(与 docs/auth/02 对齐)

  1. ✅ OP:会话袋 + prompt 处理 + 选择器页 + /auth/sessions API + 管理员 step-up(DB 迁移 kun_galgame_infrasessionsbrowser_id 等)。单账号行为不变。
  2. ✅ 账号中心(apps/web,同 OP 家族)站内切换器(in-place switch + 角色徽标)。
  3. 🚧 逐站接入 forum / moyu / wiki 切换器 UI(wiki ✅ 已接入;forum / moyu 待做)+ prompt=none 焦点对齐(待做)。
  4. ⏳ 调短 access TTL + 开审计日志。

变更摘要

2026-06-25(登出语义):澄清 §3.4——单次「退出登录」只登出当前账号(非清空整个袋子;这非 RFC 强制、Google 网页版「登出全部」是其取舍),多账号应提供「退出当前账号」+「退出全部账号」两个动作;补充与 RP 发起登出(07)的区别。apps/web 账号中心切换器据此实现(退出当前 = 先切到剩余账号再 logout,退出全部 = logout-all)。

2026-06-24(实现):后端 + OP 账号选择器落地——会话袋(sessions.browser_id/auth_time/last_used_at)、prompt=select_account|none + login_hint/auth/sessions(+ switch/logout/logout-all,Bearer + confused-deputy 防护)、管理员 step-up(10016);apps/web 站内切换器 + wiki 切换器(本地缓存 + 重定向);SessionBriefroles(角色徽标 + 「切换需重新登录」提示)。错误码复用 10xxx(非 18xxx)。forum / moyu 切换器待接入。

2026-06-24(设计):新增本文。多账号切换契约:会话袋在 OP;切换走 prompt=select_account 重定向;全局活跃 = 焦点对齐(同 .kungal.com 可瞬时、moyu 跨 TLD 焦点对齐);登出 = 撤销 + 短 TTL;管理员切入需 prompt=login 重登。安全沿用 07 的白名单 + 全程 state+PKCE。

源:kun-galgame-infra/docs/integration/oauth/09-account-switching.md