KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 全景与选型:三条协议各在哪一层 — keel 龙骨

这一章先不教你配任何东西。它要解决的是另一件事:把「登录」这个词拆成三个可以分开判断的问题—— 谁在证明你是谁、用什么把它证明出去、以及这些结论在哪一层生效。 拆不开这三个问题,选型就只能是「同事说用 OIDC 那就用 OIDC」。

这一章先不教你配任何东西。它要解决的是另一件事:把「登录」这个词拆成三个可以分开判断的问题——
谁在证明你是谁、用什么把它证明出去、以及这些结论在哪一层生效。
拆不开这三个问题,选型就只能是「同事说用 OIDC 那就用 OIDC」。


一、为什么会有「登录」这件事变得复杂

一个系统里,「登录」是最简单的一段代码:拿用户名去表里查一行,比对密码,通过就写个 session。

当一个系统变成多个系统,这段代码会遇到三个它解决不了的问题:

  1. 账号割裂。三个系统三张 user 表,同一个人有三个账号。改一次密码要改三处;
    一个人离职,管理员要记得去三处删;只要漏掉一处,那个账号就是一条后门。
  2. 无法对齐同一个人。系统 A 里的「张三」和系统 B 里的「张三」在数据库里是两个毫不相干的
    id。想做一个跨系统的操作日志、想把 A 的审批结果带到 B,就得先解决「这是不是同一个人」。
  3. 每加一个系统,安全面复制一份。密码校验、找回密码、多因素、登录风控、
    异常登录告警——每套账号体系都要各自实现一遍,而且实现水平参差。

把「验身份」这件事收敛到一个系统去做,其余系统只接受它的结论,就是所谓单点登录
(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

这张图里有两个必须看清的东西:

选型:一个可以照做的判断顺序

按这个顺序问四个问题,答案基本就出来了:

  1. 对面是谁? 如果是企业既有 IdP,先问它是 OIDC 还是 SAML——这一条常常一票决定,
    因为改对端的成本远高于改自己。
  2. 客户端拿得住秘密吗? 浏览器里跑的 SPA、移动端拿不住秘密,只能走公开客户端 + PKCE;
    有可信后端的走机密客户端 + client_secret。这与选 OIDC 还是 SAML 无关,但决定了实现细节。
  3. 请求方是"人"还是"系统"? 人 → 走带 openid 的认证流;系统对系统 →
    client_credentials 一类的授权流程,它不返回 ID Token,因为它不代表任何用户。
    把这两类混起来,会得到「一个不存在的用户」这种最难查的故障。
  4. 要传输的只是身份,还是连权限一起? 身份结论是 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

要观察三件事,而不是「看到输出就算跑过」:

  1. 第一步 GET 的状态码是 200,不是 302。 没有会话时,授权端点给你的是一张登录页——
    这是本节第 4 问里「客户端类型」之外最容易被忽略的一点:协议流里有一次人机交互。
  2. 第二步 POST 之后才出现 code,且回跳参数正好是四个:
    state / session_state / iss / code。第三节那两行 URL 里多出来的参数,
    每一个都在这里对上了号。
  3. 换回来的东西有三样: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) 项目做到一半发现对端不支持,返工的是整个接入层

自测题

  1. 用一句话分别说清 OAuth 2.0、OIDC、SAML 2.0 各自「回答什么问题」,
    并解释为什么「OAuth 2.0 不能告诉你用户是谁」。
  2. 第一节的第三个问题(每加一个系统安全面复制一份)在选型时对应哪条约束?
    如果你的产品要接 30 家客户,这条约束会怎么改变你的架构?
  3. scope 里加不加 openid,产出的差别是什么?如果漏了它,你会看到什么现象?
  4. 本课与《权限与多租户》的接缝在哪一件事上?举一个「认证成功但授权失败」的具体场景。

现在能解释什么

进入 keel 阅读