Files
Qiniu/technical/sso/sso-saml.md
T

193 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [sso, saml, xml, auth]
create time: 2026-07-13 10:03
---
# SAML 2.0
## 概述
SAML(Security Assertion Markup Language)2.0 是基于 XML 的**联合身份认证标准**,由 OASIS 于 2005 年发布。它是企业级 SSO 的事实标准,广泛用于 SaaS 应用集成(如 AWS、Salesforce、Workday 等),尤其在已有 LDAP/AD 的企业环境中几乎是必选项。
> [!info] SAML vs OIDC 的市场定位
> SAML 是企业时代的产物——XML 繁重但可靠,适合 B2B 场景。OIDC 是云原生时代的产物——JSON 轻量灵活,适合 B2C 和移动端。两者并存,不是替代关系。
## 核心概念
### 角色
| 角色 | 全称 | 说明 |
|------|------|------|
| **IdP** | Identity Provider | 身份提供商,如 AD FS、Okta、OneLogin |
| **SP** | Service Provider | 业务应用,依赖 IdP 做认证 |
| **Principal** | End User | 发起 SSO 的终端用户 |
### 关键数据结构
**Assertion(断言)** — SAML 的核心数据单元,包含:
| 字段 | 说明 |
|------|------|
| `Issuer` | 断言签发者(IdP) |
| `Subject` | 被认证的用户 |
| `Conditions` | 有效期、受众限制 |
| `AuthnStatement` | 认证方式、时间 |
| `AttributeStatement` | 用户属性(角色、邮箱等) |
## 核心流程(SP-Initiated SSO)
SP 发起的 SSO 是最常见的流程:
```mermaid
sequenceDiagram
participant U as 用户
participant SP as Service Provider
participant IdP as Identity Provider
U->>SP: 1. 访问受保护页面
SP->>U: 2. 生成 AuthnRequest,302 重定向
Note right of SP: SAMLRequest (Base64 + Deflate)
U->>IdP: 3. 重定向到 IdP SSO 端点
IdP->>U: 4. 展示登录页
U->>IdP: 5. 输入凭证
IdP->>IdP: 6. 验证凭证
IdP->>U: 7. 返回 HTML Form(自动提交)
Note left of IdP: SAMLResponse (Base64 XML)
U->>SP: 8. POST 到 ACS URL
SP->>SP: 9. 验证签名 + 解析 Assertion
SP-->>U: 10. 建立 Session,跳转目标页
```
> [!tip] 为什么用 POST 而不是 GET?
> SAMLResponse 是一段大的 Base64 XML,超过 URL 长度限制。所以 IdP 返回一个自动提交的 HTML Form,用 POST 送到 SP 的 ACS(Assertion Consumer Service)端点。
### AuthnRequest 示例
```xml
<samlp:AuthnRequest
ID="_abc123"
Version="2.0"
IssueInstant="2026-07-13T02:03:00Z"
Destination="https://idp.example.com/sso/saml"
AssertionConsumerServiceURL="https://app.example.com/saml/acs">
<saml:Issuer>https://app.example.com</saml:Issuer>
<samlp:NameIDPolicy
Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress"
AllowCreate="true"/>
</samlp:AuthnRequest>
```
### SAMLResponse 示例(简化)
```xml
<samlp:Response
Destination="https://app.example.com/saml/acs"
InResponseTo="_abc123">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature>...</ds:Signature> <!-- XML 数字签名 -->
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="_def456" IssueInstant="2026-07-13T02:03:05Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature>...</ds:Signature>
<saml:Subject>
<saml:NameID Format="...emailAddress">user@example.com</saml:NameID>
<saml:SubjectConfirmation>
<saml:SubjectConfirmationData
NotOnOrAfter="2026-07-13T02:08:05Z"
Recipient="https://app.example.com/saml/acs"
InResponseTo="_abc123"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="..." NotOnOrAfter="...">
<saml:AudienceRestriction>
<saml:Audience>https://app.example.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="...">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
```
## SP 配置要点
接入 SAML 时,你需要向 IdP 提供以下信息:
| 配置项 | 说明 | 示例 |
|--------|------|------|
| **Entity ID** | SP 的唯一标识 | `https://app.example.com` |
| **ACS URL** | 接收 SAMLResponse 的端点 | `https://app.example.com/saml/acs` |
| **NameID Format** | 用户标识格式 | `emailAddress` / `persistent` |
| **证书** | SP 的公钥(用于加密 Assertion) | PEM 格式证书 |
从 IdP 获取的配置:
| 配置项 | 说明 |
|--------|------|
| **SSO URL** | IdP 的登录端点 |
| **SLO URL** | 单点登出端点(可选) |
| **IdP 证书** | 用于验证 SAMLResponse 签名 |
## Go 接入方案
Go 生态中 SAML 库相对 OIDC 较少,推荐:
```go
// 使用 crewjam/saml 库
import "github.com/crewjam/saml/samlsp"
// 创建 SAML 中间件
samlSP, _ := samlsp.New(samlsp.Options{
EntityID: "https://app.example.com",
URL: *rootURL,
Key: tlsCert.PrivateKey.(crypto.Signer),
Certificate: tlsCert.Leaf,
IDPMetadata: idpMetadataURL, // IdP 的 Metadata XML URL
})
// 注册路由
http.Handle("/saml/", samlSP) // SAML 端点(ACS、Metadata)
http.Handle("/protected", samlSP.RequireAccount(handler)) // 受保护路由
```
## 常见陷阱与最佳实践
**XML Signature Wrapping 攻击**
- SAML 最大的安全风险。攻击者在 XML 中插入额外的 Assertion,利用解析器的逻辑漏洞
- 校验签名后,必须**重新提取** Subject 和 Conditions,不要信任解析位置
**时钟偏移**
- SAML Assertion 的有效期通常只有 5 分钟,IdP 和 SP 之间时钟不同步会导致验证失败
- 建议允许 30 秒 - 2 分钟的时钟偏移容差
**Logout 的复杂性**
- SAML SLO(Single Logout)需要所有 SP 协同,实际部署中经常不工作
- 很多企业选择**仅清除本地 Session**,不做真正的 SLO
**Metadata 自动刷新**
- IdP 的签名证书会轮换,SP 应定期拉取 IdP Metadata XML 更新证书
- 不要硬编码 IdP 证书
**NameID 的选择**
| Format | 特点 | 适用场景 |
|--------|------|----------|
| `emailAddress` | 可读,但邮箱会变 | 外部用户 |
| `persistent` | 不可读的随机 ID | 需要隐私保护 |
| `transient` | 每次登录不同 | 一次性访问 |
> [!warning] 不要用 NameID 做业务关联
> NameID 是认证标识,不是业务 ID。如果用户邮箱变了,NameID 变了,你的业务系统不应该因此丢失用户数据。用一个稳定的内部 ID 做映射。