KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 会话与登出:单点登录靠什么成立 — keel 龙骨

「登录过」这三个字,在 SSO 里不是一个状态,而是三处状态的组合: 浏览器上的 cookie、IdP 侧的会话、应用自己的会话。 单点登录能成立,是因为第二处还在;登出之所以难,是因为三处都要清—— 而第三处不归 IdP 管。

「登录过」这三个字,在 SSO 里不是一个状态,而是三处状态的组合:
浏览器上的 cookie、IdP 侧的会话、应用自己的会话。
单点登录能成立,是因为第二处还在;登出之所以难,是因为三处都要清——
而第三处不归 IdP 管。


一、现场:登出一个系统,另一个还登着

两个应用接了同一个 IdP。用户在 A 里点了「退出登录」,回到 A 的首页确实要重新登录了;
打开 B,还是登录状态。于是用户合理地认为「这个系统有安全问题」。

这里实际上发生了两件独立的事:

  1. IdP 侧的会话被作废了(否则 A 也退不掉);
  2. B 自己那份会话没有被清。B 的会话是 B 自己签发的,
    IdP 说「那个人登出了」并不会自动让 B 手里的那份凭据消失。

要理解这件事,得先把「已登录」这件事到底记在哪几处搞清楚。

二、三处状态,各自归谁管

状态 记在哪 谁负责清
浏览器上的 IdP 会话 cookie 浏览器 IdP(用户访问登出端点时)
IdP 侧的会话记录 IdP IdP
应用自己的会话 应用 应用自己

三处的关系是:浏览器带着第一处去 IdP,IdP 认了第二处,才给应用发结论;
应用拿到结论之后,自己签发第三处。
所以:

单点登录成立 = 浏览器侧的 IdP 会话 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 相同 ⇒ 应用能对上同一个人

这张对照里有两条信息,方向相反:

五、单点登出为什么难

用户点一次登出,理想情况是所有应用都退出。现实里这是整个身份体系中最不整齐的一块。
先看登出那一次的真实报文:

登出前的 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 = 会话确实被销毁了)

注意三件事:

  1. 登出端点是 end_session_endpoint,应用把用户送到那里;
    id_token_hint 让 IdP 知道「要结束的是哪条会话」(本课实验台就靠它定位 sid)。
  2. 被清掉的只有「已登录」那一个 cookie,流程类 cookie 留着——清理是精确的,
    不是「把 cookie 都删掉」。
  3. 再访问要求重新登录:是否复用已有会话 = 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

按顺序观察四行:

  1. 应用 A 第一次访问:第一步 GET 是 200(看到登录页),login_pages_seen 加一;
  2. 应用 B 第一次访问:第一步 GET 是 302,login_pages_seen 不变——
    「没有登录页」是单点登录的唯一判据;
  3. 登出前后对比 cookie:OP_SESSION 消失(它扮演的就是 KEYCLOAK_IDENTITY 的角色);
  4. 登出后再访问 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,或对高敏接口做实时校验 长寿命令牌在登出后仍然可用,是一个真实的窗口
会话只记在浏览器 服务端可查、可批量失效(例如按用户强制下线) 拿不到「把某个人的所有会话踢掉」的能力,安全事件里只能干等

自测题

  1. 登录成功后浏览器侧有四个 cookie。哪一个代表「已登录」?给出你的判据。
  2. 用户换一台电脑访问同一个应用,为什么还要再登录一次?
    用本章的三处状态解释,而不是用「cookie 不同」。
  3. 用户点了登出,两个应用里 B 还登着。列出至少两种根因,并说出各自该在哪一侧修。
  4. 登出之后,一个 5 分钟前签发的 access token 还能用吗?为什么?
    这个结论对你的接口设计有什么影响?
  5. sub 相同、aud 不同——这两件事分别说明了什么?为什么 aud 那一项校验不能省?

现在能解释什么

进入 keel 阅读