KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 全景与选型:三条协议各在哪一层 — keel 龙骨
这一章先不教你配任何东西。它要解决的是另一件事:把「登录」这个词拆成三个可以分开判断的问题—— 谁在证明你是谁、用什么把它证明出去、以及这些结论在哪一层生效。 拆不开这三个问题,选型就只能是「同事说用 OIDC 那就用 OIDC」。
这一章先不教你配任何东西。它要解决的是另一件事:把「登录」这个词拆成三个可以分开判断的问题——
谁在证明你是谁、用什么把它证明出去、以及这些结论在哪一层生效。
拆不开这三个问题,选型就只能是「同事说用 OIDC 那就用 OIDC」。
一、为什么会有「登录」这件事变得复杂
一个系统里,「登录」是最简单的一段代码:拿用户名去表里查一行,比对密码,通过就写个 session。
当一个系统变成多个系统,这段代码会遇到三个它解决不了的问题:
- 账号割裂。三个系统三张
user表,同一个人有三个账号。改一次密码要改三处;
一个人离职,管理员要记得去三处删;只要漏掉一处,那个账号就是一条后门。 - 无法对齐同一个人。系统 A 里的「张三」和系统 B 里的「张三」在数据库里是两个毫不相干的
id。想做一个跨系统的操作日志、想把 A 的审批结果带到 B,就得先解决「这是不是同一个人」。 - 每加一个系统,安全面复制一份。密码校验、找回密码、多因素、登录风控、
异常登录告警——每套账号体系都要各自实现一遍,而且实现水平参差。
把「验身份」这件事收敛到一个系统去做,其余系统只接受它的结论,就是所谓单点登录
(Single Sign-On,SSO)。这里有一个必须一开始就分清的界线:
| 问题 | 名字 | 谁来回答 |
|---|---|---|
| 你是谁? | 认证(Authentication) | 本课的主题 |
| 你能做什么? | 授权(Authorization) | 另一门课,见本章第五节 |
这两件事经常被合成一句「加个登录鉴权」,但它们的机制、失效表现和排障路径完全不同。
本课只讲左边一列。
二、为什么今天更值钱
SSO 不是新东西。它今天在工程里变得更常见,有两个可核查的原因,都不是「行情」:
原因一:软件从「交付到客户机房」变成「多客户共用一套」。
一套 SaaS 系统卖给几十家客户,每一家都希望你接受他们自己的身份系统——
他们的员工在自家 IdP 里有账号,离职当天 HR 系统一停用,所有系统就该同时进不去。
这就把「接一家客户」变成了「接一家客户的 IdP」:你的系统从一个「有自己账号体系的系统」
变成了一个必须同时信任多家 IdP 的应用。这个形态在协议里叫联邦(federation),
它的难点全在 05 章。
原因二:企业侧的身份基础设施已经存在,且大多不是 OIDC。
企业里的身份系统早于互联网协议:目录服务、域账号、单点登录门户,很多是
SAML 2.0(OASIS 标准)时代的产物。也就是说,你接进去的时候,
对面用什么协议往往不是你能选的——这是选型判断里最硬的一条约束,比「哪个更新」重要得多。
三、它长什么样
先给一眼形态。下面是一次真实登录的第一步:应用把浏览器送到 IdP 的授权端点。
http://localhost:8081/realms/sso-lab/protocol/openid-connect/auth?client_id=sp-spa
&redirect_uri=http%3A%2F%2Flocalhost%3A8090%2Fcallback&response_type=code
&scope=openid+profile+email&state=AsoTwZFFJyDK-urhFMy_jw&nonce=_MJjvwPYDooEWutUkyihuA
&code_challenge=8Vklj4QX3rXa9CurOob2v3rc6v8ei2wZzj6s7Uij7tY&code_challenge_method=S256
用户在这条 URL 指向的页面上登录之后,浏览器会被送回应用,地址变成:
http://localhost:8090/callback?state=AsoTwZFFJyDK-urhFMy_jw
&session_state=VnXksyjhgJexcsLOoOE5fc8c
&iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Fsso-lab
&code=44f80113-b402-a38e-bbc0-56310447ebf8.VnXksyjhgJexcsLOoOE5fc8c.74717848-0bf1-4f44-81f2-e31422eb12c8
现在只给这一眼,逐行拆开在第 02 章。 这一眼要带走的是:
SSO 的"协议"长得并不像配置,它长得像浏览器地址栏里的两段 URL。
回到第一节说的三个问题,这两段 URL 已经把它们都指出来了:
| 你看到的 | 它回答第一节的哪个问题 |
|---|---|
client_id=sp-spa |
「谁在问」——应用要在 IdP 登记过,才有资格发起 |
整条 authorization_endpoint 请求 |
「用浏览器跑一趟」——身份结论不能凭空传,得有个双方都信的通道 |
redirect_uri=...8090/callback |
「结论送回去的正确地址」——送错地方等于把身份送给别人 |
回跳里的 code=... |
「一次性的凭据」——真正的结论要换了才拿到 |
回跳里的 state=... |
「把这次往返与这次请求对上」——防的是别人伪造一次回跳 |
回跳里的 iss=... |
「这句话是谁说的」——多 IdP 场景下必须能对上来源 |
第二个、第三个问题(怎么证明、结论怎么传)就在这五件事里;下面看第四个问题:这些协议各自站在哪一层。
四、怎么解决:三条协议各在哪一层
最要紧的一件事:OAuth 2.0 不负责告诉你「你是谁」。
| 协议 | 它到底是什么 | 产物 | 回答的问题 |
|---|---|---|---|
| OAuth 2.0(RFC 6749) | 一套授权委托框架:让一个应用在不拿到用户密码的前提下,获得访问某个资源的权限 | access token | 「这个应用能访问什么」 |
| OpenID Connect(OIDC,Core 1.0) | 在 OAuth 2.0 之上加的一层身份层:复用同一条授权码流,额外返回身份结论 | ID Token(+ 原样的 access token) | 「这个人是谁」 |
| SAML 2.0(OASIS) | 另一条独立的企业联邦路线:IdP 用一条 XML 断言说明身份,经浏览器送到应用 | Assertion | 「这个人是谁」(企业语境) |
三条协议的关系可以一句话记住:
OAuth 2.0 = 授权框架,它本身不产身份结论
OIDC = OAuth 2.0 + 身份层(这就是为什么它长得像 OAuth)
SAML 2.0 = 平行的另一条路,不是 OIDC 的前身也不是后继
为什么 OIDC 会长得像 OAuth:因为它的做法就是「借用人家的那条链路,再塞一个结论进去」。
所以你在 OIDC 里会看到大量 OAuth 词汇(code / redirect_uri / state),
以及 OIDC 自己加的那几个(nonce / id_token / at_hash)。
分不清哪些词是 OAuth 的、哪些是 OIDC 加的,是排障时第一个要跨过的坎。
一次登录要跳过几个进程边界
把第一眼那段 URL 展开成一次完整往返,形状是这样的(第 02 章逐步兑现,这里先看骨架):
flowchart TD
subgraph UA["浏览器(用户代理)"]
U1["地址栏 / cookie jar"]
end
subgraph APP["应用(SP / RP)"]
A1["生成 state / nonce / code_verifier"]
A2["/callback:校验 state 与 iss"]
A3["换 token 并校验 ID Token"]
end
subgraph IDP["IdP(OP)"]
I1["授权端点:有会话就发 code"]
I2["登录页:验证凭据"]
I3["令牌端点:发三种 token"]
I4[("会话<br/>codes")]
end
A1 -->|"① 302 到授权端点(带 state/nonce/challenge)"| I1
I1 -->|"② 200 登录页(无会话)"| U1
U1 -->|"③ POST 凭据"| I2
I2 --> I4
I2 -.->|"④ 302 带 code/state/iss/session_state"| A2
A2 --> A3
A3 -->|"⑤ 用 code + code_verifier 换 token"| I3
I3 --> I4
I3 -.->|"⑥ invalid_grant(verifier 不符 / code 已用过)"| A3
I3 -->|"⑦ access_token + ID Token + refresh_token"| A3
U1 -.->|"① 浏览器亲自跑这一趟"| I1
这张图里有两个必须看清的东西:
- 浏览器是链路的一部分(第 ①②④ 跳都经过它),这就是为什么「拿不到浏览器 cookie」
会把一条协议流打成两段; - 跨进程的数一数:浏览器 → IdP、IdP → 浏览器、浏览器 → 应用、应用 → IdP,
一共 4 个跨进程跳点。任何一跳的失败表现都长得很像(都表现为「回到应用后没登录上」),
所以 06 章要用「症状 → 去哪看」把它们分开。
选型:一个可以照做的判断顺序
按这个顺序问四个问题,答案基本就出来了:
- 对面是谁? 如果是企业既有 IdP,先问它是 OIDC 还是 SAML——这一条常常一票决定,
因为改对端的成本远高于改自己。 - 客户端拿得住秘密吗? 浏览器里跑的 SPA、移动端拿不住秘密,只能走公开客户端 + PKCE;
有可信后端的走机密客户端 +client_secret。这与选 OIDC 还是 SAML 无关,但决定了实现细节。 - 请求方是"人"还是"系统"? 人 → 走带
openid的认证流;系统对系统 →client_credentials一类的授权流程,它不返回 ID Token,因为它不代表任何用户。
把这两类混起来,会得到「一个不存在的用户」这种最难查的故障。 - 要传输的只是身份,还是连权限一起? 身份结论是 ID Token;权限信息(角色、组)
可以放进令牌,也可以每次去问——这两条路的代价见 03 章。
逐行兑现第三节那一眼
| 第三节里的那一行 | 对应本节哪条结论 |
|---|---|
client_id=sp-spa |
第 2 问:客户端要先在 IdP 登记,且类型(公开/机密)决定了它怎么被识别 |
redirect_uri=...8090/callback |
授权框架的产物要在浏览器里送回应用,所以必须先约定一个精确的接收地址 |
response_type=code |
走的是授权码流:给出去的是「凭据」不是「结论」,第 02 章讲为什么这样更安全 |
scope=openid+... |
openid 这一个值就是把 OAuth 请求变成 OIDC 请求的开关——没它就没有 ID Token |
code_challenge=... |
第 2 问的后果:公开客户端拿不住秘密,只能用一次性的 verifier 交换 |
回跳的 code=... |
「结论要换了才拿到」,而且只能用一次 |
回跳的 iss=... |
第 1 问的后果:对面可能是多家 IdP,所以每次答复都自带来源 |
五、本课与《权限与多租户》的分工
这两门课经常被混在一起问,所以这里明确划一次:
| 本课 | 《权限与多租户》 | |
|---|---|---|
| 主题 | 认证:你是谁 | 授权:你能做什么 + 数据隔离 |
| 产物 | ID Token、会话、sub |
角色、权限点、数据范围、租户条件 |
| 典型故障 | 回跳拿不到结论、令牌验不过、登出不干净 | 接口返回 403 说不清哪层拦的、跨租户看到别人的数据 |
| 与对方的接缝 | 只在一个地方相遇:同一个人的身份要落进对方的租户体系(05 章) | 那边把「认证」明确划出去,交给身份这一侧 |
那门课的开篇
第一段就把三件事拆开了:认证 / 授权 / 数据隔离。「登录成功」在那门课里只是一个前提,
不是你有了什么权限。 本课讲的每一件事都停在这个前提下。
动手:可观察结果
配套项目里的一次完整登录,跑起来是:
cd courses/identity/sso-engineering/course/project
python examples/01_authorization_code_flow.py
要观察三件事,而不是「看到输出就算跑过」:
- 第一步 GET 的状态码是 200,不是 302。 没有会话时,授权端点给你的是一张登录页——
这是本节第 4 问里「客户端类型」之外最容易被忽略的一点:协议流里有一次人机交互。 - 第二步 POST 之后才出现
code,且回跳参数正好是四个:state/session_state/iss/code。第三节那两行 URL 里多出来的参数,
每一个都在这里对上了号。 - 换回来的东西有三样:
access_token、id_token、refresh_token。
它们是三种不同的东西,aud都不一样——03 章整章讲这个差别。
故障注入
把授权请求里的 redirect_uri 端口从 8090 改成一个没登记过的值,再发出这次请求:
python - <<'PY'
import sys; sys.path.insert(0, "src")
from minioidc.lab import build_lab
from minioidc.rp import Browser
op, spa, _ = build_lab()
request = spa.begin_login()
tampered = request.url.replace("8090", "9999")
response = Browser(op, "alice", "alice-password").get(tampered)
print("status =", response.status)
print("body =", response.body.strip())
PY
预期结果是一个 400,页面上写着 Invalid parameter: redirect_uri。
这一条值得停一下:它没有把错误重定向回 redirect_uri。
如果 IdP 把「地址不认识」这个错误也发回那个地址,攻击者只要登记一个自己的地址,
就能借 IdP 的域名往任意地方发跳转。所以这一步是整条请求拒掉,一个字都不往回带。
这是「授权端点上的错误为什么有时是页面、有时是回跳」的第一层原因(06 章第 2 类故障)。
生产边界
| 教学里这样 | 真实部署要换成 | 不换会出什么事 |
|---|---|---|
| 单实例 IdP,配置写在代码里 | 有高可用的 IdP(自建集群或托管服务)+ 配置版本化 | IdP 一停,所有应用同时登不进——这是 SSO 的集中化代价,必须在选型时就认下来 |
http://localhost 上的 realm |
HTTPS + 正式域名,且 issuer 与实际访问地址一致 |
令牌里的 iss 与应用的预期差一个字符就全量验签失败(07 章) |
| 一套 realm 管所有客户 | 按信任域拆分(一客户一 realm,或一 IdP 一联邦配置) | 客户之间的用户、客户端、密钥全混在一个信任域里,无法独立轮换 |
| 不做协议区分「都用 OIDC」 | 按对端能力选(企业 IdP 常只有 SAML) | 项目做到一半发现对端不支持,返工的是整个接入层 |
自测题
- 用一句话分别说清 OAuth 2.0、OIDC、SAML 2.0 各自「回答什么问题」,
并解释为什么「OAuth 2.0 不能告诉你用户是谁」。 - 第一节的第三个问题(每加一个系统安全面复制一份)在选型时对应哪条约束?
如果你的产品要接 30 家客户,这条约束会怎么改变你的架构? scope里加不加openid,产出的差别是什么?如果漏了它,你会看到什么现象?- 本课与《权限与多租户》的接缝在哪一件事上?举一个「认证成功但授权失败」的具体场景。
现在能解释什么
- 为什么「多系统 + 多客户」这件事会把登录从一个函数变成一套协议;
- 三条协议各在哪一层、为什么 OIDC 长得像 OAuth、SAML 不是它的旧版本;
- 一次登录要跨 4 个进程跳点,以及为什么任何一跳失败的表现都长得一样;
- 选型的四问顺序,以及为什么「对面是谁」常常一票决定;
- 第一节那两行 URL 里每个参数各自在回答什么问题——它们将在 02 章被逐个打开。