KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 会话与登出:单点登录靠什么成立 — keel 龙骨
「登录过」这三个字,在 SSO 里不是一个状态,而是三处状态的组合: 浏览器上的 cookie、IdP 侧的会话、应用自己的会话。 单点登录能成立,是因为第二处还在;登出之所以难,是因为三处都要清—— 而第三处不归 IdP 管。
「登录过」这三个字,在 SSO 里不是一个状态,而是三处状态的组合:
浏览器上的 cookie、IdP 侧的会话、应用自己的会话。
单点登录能成立,是因为第二处还在;登出之所以难,是因为三处都要清——
而第三处不归 IdP 管。
一、现场:登出一个系统,另一个还登着
两个应用接了同一个 IdP。用户在 A 里点了「退出登录」,回到 A 的首页确实要重新登录了;
打开 B,还是登录状态。于是用户合理地认为「这个系统有安全问题」。
这里实际上发生了两件独立的事:
- IdP 侧的会话被作废了(否则 A 也退不掉);
- B 自己那份会话没有被清。B 的会话是 B 自己签发的,
IdP 说「那个人登出了」并不会自动让 B 手里的那份凭据消失。
要理解这件事,得先把「已登录」这件事到底记在哪几处搞清楚。
二、三处状态,各自归谁管
| 状态 | 记在哪 | 谁负责清 |
|---|---|---|
| 浏览器上的 IdP 会话 cookie | 浏览器 | IdP(用户访问登出端点时) |
| IdP 侧的会话记录 | IdP | IdP |
| 应用自己的会话 | 应用 | 应用自己 |
三处的关系是:浏览器带着第一处去 IdP,IdP 认了第二处,才给应用发结论;
应用拿到结论之后,自己签发第三处。 所以:
单点登录成立 = 浏览器侧的 IdP 会话 cookie 还在 ← 不是「用户登录过」
这一句话解释了两个常见的困惑:
- 为什么换个浏览器要重新登录一次?因为第一处不在那台机器上。
- 为什么清了 cookie 之后「明明刚登录过」还要再登一次?因为「登录过」这件事
本来就不记在用户身上,记在那次浏览器会话上。
三、机制证据:cookie 的前后对比
一个用户登录之后,浏览器侧拿到的 cookie 是这四个:
登录成功后浏览器侧的 cookie: AUTH_SESSION_ID, KC_AUTH_SESSION_HASH, KEYCLOAK_IDENTITY, KEYCLOAK_SESSION
(有 KEYCLOAK_IDENTITY 就说明『已登录』这件事记在了哪个 cookie 上)
这里四个 cookie 各自负责不同的阶段,只有一个是「已登录」的载体:
| cookie | 它是什么 | 有没有它代表什么 |
|---|---|---|
AUTH_SESSION_ID |
这一次授权/登录流程的临时标识 | 登录前后都在,它描述「流程」不是「结果」 |
KC_AUTH_SESSION_HASH |
上面那个标识的校验值 | 同上,防止被篡改 |
KC_RESTART |
中断后可以恢复的那次授权请求 | 登录前后都在 |
KEYCLOAK_IDENTITY |
「这个人已经登录」的凭据 | 有它 = 已登录 |
这个区分的意义在于:如果你把「登录页出现过」或「AUTH_SESSION_ID 存在」当成已登录,
你的判定会在用户还没提交凭据时就成立。 看 KEYCLOAK_IDENTITY 才是对的。
四、单点登录成立的那一次:第一步 GET 就 302
带着上面那份 cookie 去访问第二个应用,看它的第一步会发生什么:
GET auth -> 302 | Location: http://localhost:8090/callback?state=2wfi9dcYzQwhvZWZG7CqLg&session_state=4qMwHUb8rTkW7PzAiXVThLcy&iss=http%3A%2F%2Floca...
★ 是否复用已有会话 = True (True = 单点登录成立)
★ 注意:这一次整个流程里没有出现登录页,也没有再提交一次密码
对比 02 章第一步的 status = 200(给登录页)——差异只有一个状态码,
但它说明了整件事:授权端点在「第一步」就已经知道你是谁,因为浏览器带着 KEYCLOAK_IDENTITY。
flowchart TD
subgraph UA["浏览器"]
C1["cookie:AUTH_SESSION_ID<br/>KC_AUTH_SESSION_HASH<br/>KC_RESTART"]
C2["cookie:KEYCLOAK_IDENTITY<br/>本来就有 = 已登录"]
end
subgraph IDP["IdP"]
S1[("登录会话<br/>sessions")]
S2["授权端点"]
S3["登出端点<br/>end_session_endpoint"]
end
subgraph A["应用 A(sp-spa)"]
A1["自己的会话 + 应用侧 cookie"]
end
subgraph B["应用 B(sp-portal)"]
B1["自己的会话 + 应用侧 cookie"]
end
C1 -->|"① 访问 A:授权端点看到只有流程 cookie"| S2
S2 -.->|"200 登录页 + 提交凭据"| C2
C2 --> S1
S2 -->|"② 302 带 code(本次登录)"| A1
C2 -->|"③ 访问 B:授权端点看到 IDENTITY"| S2
S2 -->|"④ 302 带 code,全程无登录页"| B1
A1 -->|"sub 相同、aud 不同"| B1
C2 -->|"⑤ 用户点登出(带 id_token_hint)"| S3
S3 -->|"⑥ 清 IdP 会话,清掉 KEYCLOAK_IDENTITY"| S1
S3 -->|"⑦ front-channel:经浏览器的 iframe 通知应用"| A1
S3 -->|"⑧ back-channel:服务端直连应用的登出地址"| B1
S3 -.->|"⑨ 应用侧会话没清:B 仍视为已登录"| B1
S1 -.->|"⑩ 会话不在时:下一次访问又回到登录页"| S2
两个应用认到的是同一个人
sp-spa sub = 8b0e33a7-5e5b-4051-a20e-417fa5b1cf5d | aud = sp-spa
sp-portal sub = 8b0e33a7-5e5b-4051-a20e-417fa5b1cf5d | aud = sp-portal
★ 同一个用户、两个应用,sub 相同 ⇒ 应用能对上同一个人
这张对照里有两条信息,方向相反:
sub相同:这是单点登录的价值——两个应用终于能确认「这是同一个人」。
它也是 05 章讨论联邦时的起点:在同一个 IdP 下sub稳定。aud不同:每个应用拿到的结论都是发给自己的。这正是 03 章那条aud校验的必要性:
没有它,B 那个 id_token 也能在 A 里通过校验。
五、单点登出为什么难
用户点一次登出,理想情况是所有应用都退出。现实里这是整个身份体系中最不整齐的一块。
先看登出那一次的真实报文:
登出前的 cookie: AUTH_SESSION_ID, KC_AUTH_SESSION_HASH, KEYCLOAK_IDENTITY, KEYCLOAK_SESSION, KC_RESTART
GET logout -> 302 | Location: http://localhost:8090/callback
登出后的 cookie: AUTH_SESSION_ID, KC_AUTH_SESSION_HASH, KC_RESTART
(KEYCLOAK_IDENTITY 消失 = 『已登录』这件事被作废了)
--- 登出后再访问第一个应用(sp-spa)---
GET auth -> 200 | Location: (无)
★ 是否复用已有会话 = False (False = 会话确实被销毁了)
注意三件事:
- 登出端点是
end_session_endpoint,应用把用户送到那里;id_token_hint让 IdP 知道「要结束的是哪条会话」(本课实验台就靠它定位sid)。 - 被清掉的只有「已登录」那一个 cookie,流程类 cookie 留着——清理是精确的,
不是「把 cookie 都删掉」。 - 再访问要求重新登录:
是否复用已有会话 = False。这是「IdP 侧会话真的没了」的判据,
而不是「cookie 没了」。
难点一:会话在两侧,IdP 只能清自己那一侧
IdP 能保证的是「第二处(IdP 会话)被清掉」。第三处(应用自己的会话)是应用的责任。
IdP 想帮忙,只有两条通道(discovery 文档里都写着 true):
| 通道 | 怎么走 | 优点 | 缺点 |
|---|---|---|---|
| front-channel | IdP 让浏览器去访问各应用的一个登出地址(通常是 iframe) | 不需要应用从公网可达 | 依赖页面还开着;第三方 cookie 限制会打折;用户关掉标签页就没人可通知 |
| back-channel | IdP 服务端直接调用应用的登出地址 | 不依赖浏览器 | 要求应用的登出地址从 IdP 可达(防火墙 / NAT 后面就不行);要有签名与防重放 |
两条通道都不是默认必然生效的:它们是配置项。一个不实现登出通知的应用,
在单点登出里永远表现为「怎么还登着」。
难点二:令牌不会因为登出而失效
这是最容易被误判的一条。KEYCLOAK_IDENTITY 消失,意味着下一次登录不会复用会话;
但已经签发出去的 access token 在过期前仍然可以用——
它是一个自包含的凭据,不会因为 IdP 删了会话而自动作废。
登出影响的是:下一次「拿 code」不会再免密发生
登出不影响的是:已经发出的 access token 在 exp 之前仍然有效
所以「登出后立刻撤权」这件事,要么靠短寿命令牌把窗口压小,
要么额外接一套撤销/校验机制(03 章第五节那个取舍)。
难点三:session_state 只解决「感知」,不解决「清理」
session_state 与会话状态绑定,前端可以通过它在会话变化时做反应
(discovery 里那个 check_session_iframe 就是为此存在的)。但请区分:
- 感知:前端知道「会话在别处没了」;
- 清理:应用真的把自己的会话删掉。
很多系统只做了前者,于是用户看到「界面变了」但接口还认他——比不做更迷惑。
动手:可观察结果
cd courses/identity/sso-engineering/course/project
python examples/05_sso_and_logout.py
按顺序观察四行:
- 应用 A 第一次访问:第一步 GET 是 200(看到登录页),
login_pages_seen加一; - 应用 B 第一次访问:第一步 GET 是 302,
login_pages_seen不变——
「没有登录页」是单点登录的唯一判据; - 登出前后对比 cookie:
OP_SESSION消失(它扮演的就是KEYCLOAK_IDENTITY的角色); - 登出后再访问 A:又回到 200,
login_pages_seen变成 2——会话真的销毁了。
故障注入
| 破坏 | 改法 | 预期现象 | 说明 |
|---|---|---|---|
| 抹掉会话 cookie | browser.cookies.pop("OP_SESSION") 后再访问另一个应用 |
第一步变回 200 | 证明单点登录只依赖这一个 cookie |
| 只清浏览器 cookie、不走登出端点 | 删掉 OP_SESSION 后再把同一个值塞回去,然后访问另一个应用 |
第一步仍是 302:依旧免密 | 「应用自己实现的登出」若只清 cookie,服务端会话还在,cookie 一回来就复现 |
| 走完登出端点再塞回同一个 cookie 值 | 先调 end_session,再把旧 OP_SESSION 值塞回去 |
第一步回到 200 | 与上一条对照:真正的登出动了服务端会话,不是清了浏览器那一半 |
让 id_token_hint 指向另一条会话 |
拿另一个用户登录得到的 id_token 作为 hint 传进登出端点 |
IdP 侧被清掉的是 hint 指向的那条会话,另一条还在 | id_token_hint 决定清哪一条会话,别拿用户输入去拼它 |
| 应用不实现登出通知 | 只让 IdP 侧登出,应用侧会话不动 | 应用仍显示已登录 | 这是本章第一节那个现象的最小复现 |
第二、三条要连着做:它们把「清 cookie」与「清会话」的差别摆得非常清楚——
用户界面上的『已退出』和服务端的『已作废』是两件事,
只有 end_session 那一步(带 id_token_hint)才真正动服务端那一条会话记录。
生产边界
| 教学里这样 | 真实部署要换成 | 不换会出什么事 |
|---|---|---|
| 会话存在进程内存字典 | 共享存储(Redis 一类)+ TTL | 多副本部署时第一步在 A 副本、第二步落到 B 副本,会话找不到;重启即全员掉线 |
会话 cookie 无 Domain / Path 约束 |
明确 Domain / Path / SameSite,并把 IdP 与应用分域 |
子域之间的 cookie 会互相影响,等于把信任域放大 |
| 一个 IdP 域下的两个应用 | 若跨站,评估 SameSite=None 与第三方 cookie 限制 |
前端 iframe 式登出通知会被浏览器拦掉,单点登出看着「有时灵有时不灵」 |
只实现 end_session_endpoint |
按需实现 front-channel / back-channel 登出通知 | 应用侧会话残留,用户会认为登出没生效 |
| 登出后立刻停用令牌 | 短寿命 access token,或对高敏接口做实时校验 | 长寿命令牌在登出后仍然可用,是一个真实的窗口 |
| 会话只记在浏览器 | 服务端可查、可批量失效(例如按用户强制下线) | 拿不到「把某个人的所有会话踢掉」的能力,安全事件里只能干等 |
自测题
- 登录成功后浏览器侧有四个 cookie。哪一个代表「已登录」?给出你的判据。
- 用户换一台电脑访问同一个应用,为什么还要再登录一次?
用本章的三处状态解释,而不是用「cookie 不同」。 - 用户点了登出,两个应用里 B 还登着。列出至少两种根因,并说出各自该在哪一侧修。
- 登出之后,一个 5 分钟前签发的 access token 还能用吗?为什么?
这个结论对你的接口设计有什么影响? sub相同、aud不同——这两件事分别说明了什么?为什么aud那一项校验不能省?
现在能解释什么
- 「已登录」在 SSO 里不是一个状态,而是三处状态的组合,以及每处归谁管;
- 为什么单点登录的判据是「第二个应用第一步 GET 就 302」,
以及KEYCLOAK_IDENTITY类 cookie 才是那个载体; - 单点登出的三条难点:会话在两侧、令牌不会因登出失效、
session_state只管感知; - 两个应用
sub相同 /aud不同分别意味着什么; - 为什么「用户界面上显示已退出」与「服务端已作废」需要分别验证。