OIDC 与 SAML — 校验什么才算安全
一句话总结
无论 OIDC 还是 SAML,真正提供安全性的都不是协议名称,而是接收方的验证清单。签名、iss、aud、exp、nonce 中漏掉任何一项,攻击者都可能从那个缺口进入。
为什么这是个问题
即使省略验证,集成在正常流程中仍会完美运行:能够登录,页面正常显示,用户名也正确。因此开发与集成测试期间不会出现任何信号。
遗漏的验证只会在两个时刻暴露:接受安全检测时,或事故发生时。不验证签名,任何人都能修改 payload 后冒充任意用户;不验证 aud,签发给其他客户端的令牌也能在本应用通过。这不是实现难度问题,而是是否知道完整清单的问题。
OIDC Authorization Code 流程
这是带浏览器的 Web 应用标准流程,共五步。
1. 사용자가 우리 앱의 보호된 페이지 요청
2. 앱 → 브라우저를 IdP 로 리다이렉트
GET /authorize?response_type=code&client_id=labhub-web
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=<임의값>&nonce=<임의값>
3. IdP 가 로그인 처리 후 브라우저를 우리 앱으로 리다이렉트
302 Location: https://app.example.com/callback?code=<인가코드>&state=<임의값>
4. 앱(서버)이 백채널로 토큰 교환
POST /token grant_type=authorization_code&code=...&redirect_uri=...
→ { access_token, id_token, refresh_token, expires_in }
5. 앱이 id_token 을 검증하고 세션을 만든다
为什么要先换取一次 code。 如果把令牌直接放进浏览器 URL,它会留在浏览历史、Referer 和日志中。授权码是一次性的,寿命很短;真正的令牌通过服务器到服务器的后端通道获取,因此更安全。
state 与 nonce 的职责不同,经常被混淆。
state——防止 CSRF,确认回调回到了发起请求的浏览器会话。应比较回调中的 state 与最初发送的值。nonce——防止令牌重放。检查 ID 令牌内部是否包含相同值。即使有人捡到以前签发的 ID 令牌,也会因 nonce 不同而被拒绝。
ID 令牌必须验证什么
ID 令牌是 JWT,由点号连接 헤더.페이로드.서명 三部分,各自使用 base64url。任何人都能解码。 真正提供安全性的是签名验证。
| 验证项 | 不验证会发生什么 |
|---|---|
| 签名 | 任何人都能修改 payload 后以管理员身份登录 |
iss(签发方) |
攻击者用自己的 IdP 签发令牌也能通过 |
aud(受众) |
签发给其他应用的令牌也能通过(令牌替换) |
exp(过期) |
已过期令牌永远有效 |
nonce |
旧令牌可以重复使用 |
alg |
遭受 alg: none 或算法混淆攻击 |
最后一项是著名陷阱。如果 JWT 库信任令牌自称使用的算法,攻击者就能改成 alg: none,在没有签名的情况下通过。验证代码必须固定为我们预期的算法。
实际项目还必然遇到时钟问题。验证 exp/iat 时,应允许大约 60~120 秒的 clock skew。一台 NTP 偏移的服务器就可能制造间歇性登录失败,而这种故障通常很难定位。
SAML 2.0——XML,签名决定一切
SAML 通过浏览器 POST 传递一个 XML 文档(Response 内含 Assertion),结构如下。
<samlp:Response Destination="https://app.example.com/saml/acs"
InResponseTo="_req123" IssueInstant="...">
<saml:Issuer>https://idp.example.com/</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="...:Success"/></samlp:Status>
<saml:Assertion>
<saml:Issuer>https://idp.example.com/</saml:Issuer>
<ds:Signature>...</ds:Signature> ← 서명
<saml:Subject>
<saml:NameID Format="...emailAddress">hong@labhub.co.kr</saml:NameID>
<saml:SubjectConfirmation>...</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="..." NotOnOrAfter="...">
<saml:AudienceRestriction>
<saml:Audience>https://app.example.com/sp</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="department"><saml:AttributeValue>개발팀</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
SP(我们的系统)要验证的内容与 OIDC 一一对应。
- 签名——Response 或 Assertion 是否有有效签名;并确认我们信任的数据确实位于签名覆盖范围内
Issuer——是否为我们登记的 IdPAudience——是否签发给我们的 SPNotBefore/NotOnOrAfter——当前是否在有效期内(允许 clock skew)InResponseTo——是否为我们发送的请求所对应的响应Destination——是否发送到我们的 ACS URL- 防止重用——同一个 Assertion ID 是否已使用过
第 1 项的第二句话正是 XML Signature Wrapping(XSW)攻击的核心。攻击者把原始 Assertion 藏在文档某处,再把未签名的伪造 Assertion 放到解析器读取的位置。结果是签名验证通过,应用读取的数据却是假的。 因此,不仅要问“签名是否有效”,还要确认“我读取的节点是否就是被签名的节点”。这很难,SAML 不应自行实现,而应使用经过验证的库。
SAML 证书过期——悄然到来的全面故障
SAML 要求 SP 预先登记 IdP 的签名证书。证书过期时,所有用户会同时无法登录。 大型迁移项目中,曾发生因签名证书过期导致 47 分钟全面登录故障的案例。
防御方法很简单:必须把 SAML 签名证书加入证书到期监控列表。多数列表只监控服务器 TLS 证书,因此会漏掉它。
OIDC 与 SAML——实际选择
| 项目 | SAML 2.0 | OIDC |
|---|---|---|
| 数据格式 | XML | JSON(JWT) |
| 传递路径 | 主要是前端通道(浏览器 POST) | 前端 + 后端通道 |
| 移动应用/SPA | 不便 | 适合(PKCE) |
| 元数据交换 | XML 元数据文件 | discovery 文档(JSON) |
| 大型企业/公共机构 | 仍有大量使用 | 新项目逐渐增加 |
| 实现难度 | 高(XML 签名) | 中等 |
新系统优先 OIDC;对方已经使用 SAML,就接入 SAML。 还有一个有趣事实:在 Keycloak 等 IdP 中同时注册 OIDC 客户端与 SAML 客户端后,同一个 SSO 会话里,一边可以得到 JWT,另一边可以得到 XML Assertion。 IdP 相当于协议转换枢纽。在新旧系统并存的 SI 环境中,这一特性是迁移策略的关键。