09 — 账号切换(多账号 / Account Switching)
✅ 状态:后端 + OP 账号选择器已实现,契约稳定,下游可接入。已上线:会话袋数据层、
/oauth/authorize的prompt=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_infra跑go run ./cmd/migrate(加sessions.browser_id等列;部署不自动跑迁移)。
让用户像 Gmail/微软那样同时登录多个账号并一键切换。本文档读者 = 下游前端/后端开发; 讲「你要怎么调、能依赖什么保证」,不讲 IdP 内部表结构。
1. 模型(先理解这三句)
- IdP 持有「会话袋」:一个浏览器里登录的 N 个账号,全部由 OP(
oauth.kungal.com)服务端持有,靠一个 httpOnly 的浏览器锚点 cookie 串起来。下游不持有多账号的 refresh token。 - 每个下游 app 同一时刻只持有「当前账号」的令牌。
- 「切换」= 下游重新走一次到 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.com与www.kungal.com同属kungal.com,可让锚点 cookieDomain=.kungal.com→ kungal.com 家族内瞬时全局;只有moyu.moe走「焦点对齐」。
- 同 TLD 例外:
3.3 会话袋 API(仅同站 app,如账号中心 oauth.kungal.com/profile)
这些 JSON 端点读 OP 域上的 Lax 锚点 cookie,因此只对与 OP 同站(
kungal.com家族:oauth.kungal.com自身、wiki.kungal.com、www.kungal.com)的前端可用——同站下游若要 跨子域调用,还需 OP 为该 origin 放行 CORS(credentials)。跨 TLD 的moyu.moe用不了(SameSitecookie 不跨站fetch发送)→ 一律走 §3.1 重定向 + §3.6 本地缓存。⚠️ 坑(同站接入必看):
switch/logout会返回业务性 401(10016step-up、10005不在袋中、10001非成员)。别让前端「遇 401 就刷新令牌 / 跳登录」的全局拦截器 吞掉它们——这几个调用要绕过全局 401 处理、自己读响应code分支(否则 step-up 会被 误判成会话失效而把用户登出)。参考实现:apps/webuseAccountSwitch.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(Bearer,Authorization header)——非 cookie 鉴权,天然免 CSRF,无需额外 CSRF 令牌。调用者(Bearer 身份)必须是该浏览器袋子的成员,否则 401(防 confused-deputy:仅凭锚点 cookie 不足以操作袋子)。
3.4 登出语义(本系统决策:基于撤销 / revocation-based)
关键:单次「退出登录」只应登出当前账号,不要清空整个袋子。 OIDC 规范本身倾向「按 会话登出」(用
sid指明登出哪一个会话),没有规定「登出 = 登出所有账号」;Google 网页版的「登出即登出全部」是其产品取舍(且广受诟病,移动端就是逐个登出)。多账号最佳实践 = 同时提供**「退出当前账号」+「退出全部账号」**两个动作。
- 退出当前账号(
POST /auth/sessions/logout {sub}):OP 硬删除该账号的会话(删了就刷不动 = 撤销),从袋子移除;其他账号不受影响。若删的是当前活跃账号,OP 顺带清refresh_tokencookie。推荐下游 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(脆弱)。撤销式最简单、坑最少、可日后再叠加即时传播而无需重构。
- 这样选是因为:下游是 SPA,没有服务端 RP 会话可供 back-channel logout 关联
- 与 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_hint、GET /auth/me返回身份(均已有)。
4. 安全要求(下游 必须,依据 RFC 9700 / RFC 6819)
- [ ] 每次切换/添加/对齐重定向都带一次性、绑定 user-agent 的
state+ PKCE(SPA 是 public client)。防止伪造回调把受害者静默切到攻击者账号。 - [ ] 回调/返回 URL 必须在 OP 白名单内(复用 07-logout.md 的
post-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. 下游耦合点(重命名/重构时务必同步本节)
下游会硬编码以下事实:
- OP 授权端点接受
prompt=select_account/prompt=none/prompt=login。 - 「全局活跃账号」语义 = 焦点对齐(非后台标签页即时),下游 UI 须照此设计预期。
- 登出传播 = 撤销 + 短 TTL(不是即时跨 app 登出)。
- 同站才有会话袋 JSON API;跨 TLD 只有重定向流。
7. 实施阶段(与 docs/auth/02 对齐)
- ✅ OP:会话袋 +
prompt处理 + 选择器页 +/auth/sessionsAPI + 管理员 step-up(DB 迁移kun_galgame_infra:sessions加browser_id等)。单账号行为不变。 - ✅ 账号中心(apps/web,同 OP 家族)站内切换器(in-place switch + 角色徽标)。
- 🚧 逐站接入 forum / moyu / wiki 切换器 UI(wiki ✅ 已接入;forum / moyu 待做)+
prompt=none焦点对齐(待做)。 - ⏳ 调短 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 切换器(本地缓存 + 重定向);SessionBrief带roles(角色徽标 + 「切换需重新登录」提示)。错误码复用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