07 — 登出与单点登出(RP-Initiated Logout)
返回 README
本节是跨服务契约:下游(kungal / moyu / wiki 等 RP)必须按此实现登出,否则会出现「登出后再点登录/注册,直接静默登回刚才的账号」的回归。
背景与症状
集中式 SSO 下,oauth.kungal.com 是 OP(身份提供方),各站是 RP(接入方)。用户反馈:在 wiki / 补丁站登出后,点「登录」或「注册」会直接以刚才的账号登入,没有任何提示。
根因:RP 登出 ≠ OP 登出
OP 的「登录态」由两样东西决定,都在 oauth.kungal.com 这个 origin 上:
- OP 前端
localStorage里持久化的user(驱动isLoggedIn); refresh_tokenhttpOnly cookie + DBsessions行。
RP 登出只清掉了那个站自己的状态。而:
localStorage严格按 origin 隔离 —— 任何 RP 都无法清除oauth.kungal.com的localStorage,OP 前端的user始终还在 →isLoggedIn恒为真。- 跨站(如补丁站
touchgal.moe→oauth.kungal.com)的SameSite=Laxrefresh cookie 连后台 fetch 都带不过去 → cookie / 会话也清不掉。
于是再点登录跳到 /oauth/authorize 时,OP 前端判定「已登录」+ 客户端 auto_consent=true → 静默发码 → 登回原账号。
关键结论:要清掉 OP 会话,浏览器必须顶层导航到 oauth.kungal.com —— 只有真正访问到该 origin,才能同时清掉它的 cookie/会话和 localStorage。后台 fetch 两样都做不到。这正是 OpenID Connect RP-Initiated Logout 解决的问题。
方案:RP 登出时顶层跳转到 OP 登出入口(单点登出)
对同主人的一方生态,「登出 = 退出整个鲲 SSO」是最自然的语义。流程:
用户在 RP 点登出
→ RP 清本地会话
→ 浏览器顶层跳转 {OAUTH_API_BASE}/oauth/logout?client_id=<id>&redirect=<回到RP的地址>
→ 后端 302 跳到 OP 前端登出页 /auth/logout(对称于 /oauth/authorize 跳同意页)
→ OP 前端页清 refresh cookie + DB session + OP localStorage 的 user
→ 校验 redirect 在白名单 → 跳回 RP
→ 用户回到 RP(此时 OP 会话已清;再点登录会要求重新登录)
OP 端点契约
1. 登出入口(顶层导航,对称于 /oauth/authorize)
GET {OAUTH_API_BASE}/oauth/logout?client_id=<client_id>&redirect=<post_logout_url>
# 生产: https://oauth.kungal.com/api/v1/oauth/logout?client_id=...&redirect=...
- RP 必须用
window.location.href(浏览器顶层导航)跳过来,不能用 fetch/XHR —— fetch 清不掉 OP 的localStorage/cookie(见上文根因)。 - 这是后端入口,会 302 跳到 OP 前端登出页
/auth/logout(与/oauth/authorize跳到前端同意页同一模式)。前端页执行:清 OP 会话(POST /api/v1/auth/logout删 session + 清 refresh cookie)+ 清 OP 前端localStorage的user与access_tokencookie → 校验redirect→window.location跳回。 - RP 复用访问
/oauth/authorize时用的同一个OAUTH_API_BASE(含/api/v1),无需额外配置 OP 前端域名;dev(前后端不同源)下也能正确解析(后端用cfg.Server.FrontendURL找前端页)。
| 参数 | 必填 | 说明 |
|---|---|---|
client_id |
是 | 发起登出的 RP 客户端 ID(用于校验 redirect 白名单) |
redirect |
否 | 登出后回跳地址(post-logout redirect URI)。缺省 / 校验不过 → 跳 OP 首页 |
2. 回跳白名单校验(OP 登出页内部用)
GET /api/v1/oauth/post-logout-redirect?client_id=<id>&redirect=<url>
无需鉴权。校验 redirect 的 origin(scheme+host)是否匹配该 client 注册的任一 redirect_uri 的 origin,匹配则回显,否则回空。
成功响应:
{ "code": 0, "data": { "url": "https://www.moyu.moe/?logged_out=1" } }
data.url 为空字符串表示 redirect 不在白名单(防 open-redirect),调用方应回退到安全默认地址。
白名单口径:复用 client 已注册的
redirect_uris的 origin(一方生态下,能作回调目标的 origin 即可信作登出回跳目标)。如需更严格,后续可加独立的post_logout_redirect_uris注册项。
3. prompt=login(强制重新登录,可选的「仅登出本站」语义)
GET /oauth/authorize 新增可选查询参数 prompt:
| 值 | 行为 |
|---|---|
login |
即使 OP 仍有会话,也强制显示登录界面(不走 auto-consent 静默放行) |
| (空) | 默认行为(有会话 + auto_consent → 静默发码) |
适用「只想登出本站、但本站再登要重新确认」的 RP:登出时不做全局单点登出,而是在下次发起 authorize 时带上 prompt=login。与方案 1(单点登出)二选一即可。
下游(RP)接入步骤
单点登出(推荐)——把登出按钮改成顶层跳转:
// RP 登出处理
const logout = async () => {
// 1. 清本站自己的会话(清 token / 本地 store / 调用本站后端登出)
await clearLocalSession()
// 2. 顶层跳转到 OP 登出入口,登出后回到本站。
// OAUTH_API_BASE = 你访问 /oauth/authorize 用的同一个 base(含 /api/v1)。
const back = encodeURIComponent(window.location.origin + '/')
window.location.href = `${OAUTH_API_BASE}/oauth/logout?client_id=${MY_CLIENT_ID}&redirect=${back}`
}
注册要求:回跳地址(redirect)的 origin 必须与该 client 注册的某个 redirect_uri 同源。例如 redirect_uri = https://www.moyu.moe/auth/callback,则 redirect = https://www.moyu.moe/... 合法。
注意:不要只在前端「清得更干净」就完事 ——
localStorage跨 origin 清不掉 OP 的,必须顶层跳转到 OP 登出入口。
与既有端点的关系
| 端点 | 作用 | 与本节关系 |
|---|---|---|
POST /api/v1/auth/logout |
删当前 session + 清 refresh cookie(需 Bearer) | OP 登出页内部调用它清后端会话 |
POST /api/v1/oauth/revoke |
RFC 7009 吊销指定 token | S2S 吊销;不清浏览器 OP 前端状态,不能替代登出入口 |
GET /api/v1/oauth/logout(后端入口) |
本节新增:302 跳到 OP 前端登出页 | RP 登出的正确入口(顶层导航) |
GET /auth/logout(OP 前端页) |
本节新增:清 OP 会话 + 校验回跳 | 由上面的入口 302 进来;RP 一般不直接跳这个 |
安全
- Open-redirect 防护:
redirect必须通过白名单校验(origin 匹配注册的redirect_uri),否则回退安全默认地址。 - 登出入口对未登录访问也安全(无会话可清时直接按白名单回跳 / 跳首页)。
- 顶层导航不依赖跨站 cookie,补丁站(跨主域)同样可靠。
源:kun-galgame-infra/docs/integration/oauth/07-logout.md