vault backup: 2026-07-13 11:11:37

This commit is contained in:
2026-07-13 11:11:37 +08:00
parent 9865fbf3b2
commit 7e159a087a
6 changed files with 807 additions and 515 deletions
+75 -52
View File
@@ -7,84 +7,107 @@ create time: 2026-07-13 10:03
## 概述
SSO(Single Sign-On,单点登录)是一种身份认证方案:用户只需登录一次,即可访问所有相互信任的应用系统。核心目标是**统一身份源、降低登录摩擦、集中安全管控**。
想象你在一家公司上班:OA 系统一套账号、Git 一套账号、监控平台一套账号、Wiki 又一套账号。每套都要单独注册、单独登录、单独改密码。密码多了记不住就用同一个,一个泄露全军覆没。
> [!info] 什么时候需要 SSO?
> 当你的组织内有 3 个以上的应用需要用户登录时,就该认真考虑 SSO 了。否则每多一个系统就多一套账号密码,用户体验和安全管控都是灾难。
**SSO(Single Sign-On,单点登录)** 就是解决这个问题的:**登录一次,到处通行**。就像你办了一张公司门禁卡,刷卡进大楼之后,食堂、健身房、图书馆都认这张卡,不用每个地方再登记一次。
## 核心原理
## 核心角色
SSO 的本质是一个**认证委托**模型:
在理解 SSO 流程之前,先认识三个核心角色:
| 角色 | 全称 | 一句话解释 | 生活类比 |
|------|------|-----------|----------|
| **User** | End User | 使用系统的你 | 进门的人 |
| **SP** | Service Provider(服务提供方) | 你实际要访问的业务系统 | 食堂、健身房 |
| **IdP** | Identity Provider(身份提供商) | 统一认证中心,负责验证「你是谁」 | 前台门禁系统 |
> [!tip] SP 和 IdP 的关系
> IdP 知道「你是谁」,SP 知道「你能干什么」。SP 不自己验身份,而是**委托** IdP 来做——就像食堂不查你的身份证,它只认门禁卡。
## 核心流程
SSO 的本质是「认证委托」—— SP 把「验证身份」这件事交给 IdP:
```mermaid
sequenceDiagram
participant U as 用户
participant App as 业务应用
participant IdP as 身份提供商 (IdP)
participant U as 用户 (浏览器)
participant App as 业务应用 (SP)
participant IdP as 认证中心 (IdP)
U->>App: 1. 访问受保护资源
App->>IdP: 2. 重定向到登录页
U->>IdP: 3. 输入凭证
IdP-->>U: 4. 颁发 SSO Token / Session
IdP->>App: 5. 回调 + 认证凭证
App->>IdP: 6. 验证凭证有效性
IdP-->>App: 7. 返回用户身份信息
App-->>U: 8. 授予访问权限
U->>App: 1. 访问受保护的页面
App->>U: 2. 「你还没登录,去认证中心吧」<br/>浏览器地址栏变成 IdP 的网址
U->>IdP: 3. 在 IdP 页面输入账号密码
IdP-->>U: 4. 验证通过,给用户一个「凭证」
IdP->>App: 5. 把用户带回 SP,同时带上「凭证」
App->>IdP: 6. SP 后端拿着凭证去 IdP 校验真伪
IdP-->>App: 7. 「凭证是真的,用户是张三」
App-->>U: 8. 登录成功,放行访问
```
三个核心角色:
> [!info] 浏览器重定向是什么意思?
> 步骤 2 和 5 涉及「浏览器重定向」—— 你的浏览器地址栏会从 `app.example.com` 跳到 `idp.example.com`,登录完再跳回来。这不是后台偷偷发生的,你能看到地址栏在变。
| 角色 | 全称 | 职责 |
|------|------|------|
| **User** | End User | 发起请求的终端用户 |
| **SP** | Service Provider(服务提供方) | 业务应用,依赖 IdP 做认证 |
| **IdP** | Identity Provider(身份提供商) | 统一认证中心,管理用户身份 |
## SSO 的两种实现思路
> [!tip] SP vs IdP
> 简单记忆:IdP 知道「你是谁」,SP 知道「你能干什么」。SP 把「你是谁」这件事委托给了 IdP。
### 思路一:共享 Cookie(简单但受限)
## 主流协议概览
所有应用都在同一个主域下(如 `oa.company.com`、`git.company.com`),IdP 在 `.company.com` 这个顶级域上写一个 Cookie,所有子域都能读到。
| 协议 | 时代 | 传输方式 | 典型场景 |
|------|------|----------|----------|
| CAS | 2000+ | 浏览器重定向 + Ticket | 企业内部门户、高校系统 |
| SAML 2.0 | 2005+ | XML + 浏览器重定向 | 企业级 SSO、SaaS 集成 |
| OAuth 2.0 | 2012+ | HTTPS API | 第三方授权、API 访问控制 |
| OIDC | 2014+ | HTTPS API (JWT) | 现代 Web / 移动端 / SPA |
```mermaid
graph LR
A[用户登录 idp.company.com] -->|写入 .company.com Cookie| B[oa.company.com 读取同一 Cookie]
A --> C[git.company.com 读取同一 Cookie]
A --> D[wiki.company.com 读取同一 Cookie]
```
详细文档见各子笔记:
- ✅ 实现简单,不需要复杂的协议
- ❌ 所有应用必须在同一个主域下,跨域就不行了
- ❌ 安全性低,一个子域被攻破可能波及全部
- [[sso/sso-oauth2|OAuth 2.0 授权码模式]]
- [[sso/sso-oidc|OpenID Connect (OIDC)]]
- [[sso/sso-saml|SAML 2.0]]
- [[sso/sso-cas|CAS 协议]]
- [[sso/sso-comparison|协议对比与选型指南]]
### 思路二:分布式 Token(主流方案)
## 关键概念
IdP 登录成功后颁发一个签名的 **Token**(令牌),各个 SP 独立验证这个 Token 的真伪,不需要和 IdP 实时通信。
### Session 与 Token 的区别
- ✅ 跨域、跨平台、跨组织无障碍
- ✅ SP 之间互不依赖
- ✅ 支持移动端、SPA 等各种客户端
- ❌ 实现稍复杂,需要用到标准协议(OIDC/SAML 等)
> **这是当前主流方案**,后面介绍的四种协议(CAS、SAML、OAuth 2.0、OIDC)都是基于这种思路。
## 关键概念速查
### Session vs Token
| 维度 | Session-Cookie | Token (JWT) |
|------|---------------|-------------|
| 存储位置 | 服务端 | 客户端 |
| 扩展性 | 需要共享 Session Store | 天然无状态 |
| 跨域 | Cookie 受同源策略限制 | Header 传递,无跨域问题 |
| 撤销 | 删除 Session 即可 | 需要黑名单或短过期时间 |
| **存在哪** | 服务端(需要内存/数据库存储) | 客户端(浏览器/手机自己存) |
| **怎么验证** | 每次请求带上 Cookie,服务端查表 | Token 自带签名,服务端本地验签即可 |
| **跨域能力** | Cookie 受浏览器同源策略限制,只能同域 | 放在 HTTP Header 里传,没有域限制 |
| **扩展性** | 多个 SP 需要共享 Session Store | 天然无状态,SP 各自验证即可 |
| **怎么注销** | 删掉服务端 Session 就行 | Token 发出去就收不回,靠短过期时间 |
### SSO 的两种实现思路
### 什么是 CSRF 攻击?
**1. 共享 Session(Cookie Domain 共享)**
**CSRF(Cross-Site Request Forgery,跨站请求伪造)**:攻击者诱导你的浏览器向目标网站发请求,浏览器自动带上 Cookie,目标网站以为是你本人操作。
IdP 登录后写入一个顶级域的 Cookie,所有 SP 通过访问 IdP 的验证端点来确认登录状态。实现简单但受限于同主域。
> 类比:有人拿你签过名的空白支票去取钱,银行看到你的签名就放行了。`state` 参数的作用就是在支票上写明「这张支票是给谁用的」,防止被冒领。
**2. 分布式 Token(标准协议方式)**
### 什么是重放攻击?
IdP 颁发 Token,SP 独立验证。跨域、跨平台无障碍,是当前主流方案。
攻击者截获了一个有效的请求(比如登录凭证),然后**原样再发一次**,欺骗服务器以为是新的合法请求。
> [!warning] 安全要点
> - 始终使用 HTTPS,Token 一旦泄露等同于身份被盗
> - Token 有效期不宜过长,Access Token 建议 5-30 分钟
> - 使用 `state` 参数防 CSRF,`nonce` 参数防重放攻击
> 类比:你用过的电影票被别人捡到,拿去再刷一次入场。`nonce`(Number used ONCE)参数的作用就是给每张票打上唯一编号,用过就作废。
## 推荐阅读顺序
如果你是第一次接触 SSO,建议按以下顺序阅读:
1. **本文** — 建立整体认知
2. [[sso/sso-oauth2|OAuth 2.0 授权码模式]] — 理解「授权」和「认证」的区别
3. [[sso/sso-oidc|OpenID Connect (OIDC)]] — 在 OAuth 2.0 基础上补齐「认证」
4. [[sso/sso-comparison|协议对比与选型指南]] — 了解全景,学会选型
5. [[sso/sso-saml|SAML 2.0]] / [[sso/sso-cas|CAS 协议]] — 按需阅读,了解企业级和传统方案
## 关联笔记
+138 -95
View File
@@ -7,149 +7,182 @@ create time: 2026-07-13 10:03
## 概述
CAS(Central Authentication Service)是由耶鲁大学于 2000 年开发的 SSO 协议,后由 Apereo 基金会维护。它是最早的 SSO 标准之一,设计简洁,在高校和企业内部门户中仍有广泛应用。当前版本为 CAS 3.0(CAS Protocol Specification)。
2000 年,耶鲁大学面临一个问题:校园里有图书馆系统、邮件系统、选课系统、成绩查询……每个系统都要单独登录。教授们抱怨「我记不住这么多密码」。
> [!info] CAS 的定位
> 相比 SAML 的重量级 XML 和 OIDC 的 JWT 生态,CAS 的核心优势是**简单**。整个协议可以用几段 HTTP 请求描述清楚,实现成本极低。如果你只需要企业内部几个 Web 应用的 SSO,CAS 是最省事的选择。
于是耶鲁开发了 **CAS(Central Authentication Service,中央认证服务)**——一个极简的 SSO 协议。核心思路只有一句话:**用户在统一的登录页登录,拿到一张「一次性票据」,拿着票据去各个系统换通行证**。
> [!info] 类比:游乐园的手环
> 你在游乐园门口买票(登录),工作人员给你戴上一条手环(TGT)。之后你去每个游乐设施(SP),出示手环,工作人员给你一张一次性小票(ST),验证通过后就能玩。手环是你的**登录凭证**,小票是每个设施的**访问凭证**。
>
> CAS 协议就是这个流程的标准化描述。
**当前版本**:CAS Protocol 3.0(2016 年发布,增加了属性释放能力)。
## 核心流程
CAS 的核心思路是 **Ticket 机制**:用户在 CAS Server 登录后拿到一个一次性的 Service Ticket,业务应用用这个 Ticket 去 CAS Server 换取用户身份。
```mermaid
sequenceDiagram
participant U as 用户 (浏览器)
participant App as 业务应用 (CAS Client)
participant CAS as CAS Server
participant App as 业务应用 (SP)
participant CAS as CAS Server (IdP)
U->>App: 1. 访问受保护页面
App->>U: 302 → CAS Server
Note right of App: service=https://app.example.com/callback
U->>CAS: 2. 重定向到 CAS 登录页
CAS->>U: 3. 展示登录表单
U->>CAS: 4. 提交凭证
CAS->>CAS: 5. 验证凭证
CAS->>U: 302 → service URL + Ticket
Note left of CAS: ?ticket=ST-12345-xxxxx
U->>App: 6. 携带 Ticket 回到应用
App->>CAS: 7. 后端验证 Ticket
Note right of App: GET /serviceValidate?ticket=ST-xxx&service=...
CAS-->>App: 8. 返回用户信息 (XML)
App-->>U: 9. 建立本地 Session,登录成功
App->>U: 2. 「你还没登录」<br/>302 重定向到 CAS Server
Note right of App: URL: /login?service=https://app.example.com
U->>CAS: 3. 浏览器跳到 CAS 登录页
CAS->>U: 4. 展示登录表单
U->>CAS: 5. 输入账号密码
CAS->>CAS: 6. 验证凭证,生成 Service Ticket (ST)
CAS->>U: 7. 302 跳回业务应用,URL 带上 ST
Note left of CAS: Location: https://app.example.com?ticket=ST-12345-xxxxx
U->>App: 8. 浏览器跳回应用,ST 在 URL 里
App->>CAS: 9. 应用后端拿着 ST 去 CAS 验证
Note right of App: GET /p3/serviceValidate?ticket=ST-xxx&service=...
CAS-->>App: 10. 返回用户信息(XML 格式)
App-->>U: 11. 验证通过,建立本地 Session,登录成功
```
**关键设计**:
- 步骤 7 的 `service` 参数告诉 CAS「验证完后跳回哪里」
- 步骤 9 是**后端直连** CAS,不经过浏览器——ST 不会暴露给用户
- ST 是**一次性的**,验证一次就失效,有效期通常 < 10 秒
## Ticket 类型
CAS 协议定义了多种 Ticket,各有不同用途:
CAS 用 Ticket(票据)机制管理认证状态。有两种你必须理解:
| Ticket | 前缀 | 生命周期 | 用途 |
|--------|------|----------|------|
| **TGT** | `TGT-` | 用户会话级 | CAS Server 的登录凭证,存在 Server 端 |
| **ST** | `ST-` | 一次性,< 10s | SP 验证用户身份用,用后即毁 |
| **PT** | `PT-` | 一次性 | Proxy Ticket,代理场景用 |
| **PGT** | `PGT-` | 较长 | Proxy Granting Ticket |
| **PGTIOU** | `PGTIOU-` | 短 | PGT 和 ST 的关联标识 |
### 核心 Ticket
> [!tip] 简化理解
> 对于 90% 的场景,你只需要关心 **TGT**(用户在 CAS Server 的登录态)和 **ST**(一次性验证凭证)。其他 Ticket 是为代理认证设计的,大多数接入方用不到。
| Ticket | 全称 | 一句话解释 | 生命周期 |
|--------|------|-----------|----------|
| **TGT** | Ticket Granting Ticket | **登录凭证**。用户在 CAS 登录成功后获得,相当于「游乐园手环」 | 用户会话级,存在 CAS Server 端 |
| **ST** | Service Ticket | **访问凭证**。每个 SP 访问时临时生成,相当于「游乐设施的小票」 | 一次性,验证即毁,有效期 < 10 秒 |
### Ticket 流转关系
### 高级 Ticket(代理场景,大多数情况用不到)
| Ticket | 说明 |
|--------|------|
| **PT** | Proxy Ticket,SP 代替用户去访问另一个 SP 时使用 |
| **PGT** | Proxy Granting Ticket,用于获取 PT 的凭证 |
| **PGTIOU** | PGT 和 ST 之间的关联标识 |
> [!tip] 不需要全部理解
> 如果你的场景只是「用户登录 → 访问业务系统」,**只需要理解 TGT 和 ST**。Proxy 系列 Ticket 是为链式代理认证设计的,90% 的接入方用不到。
### Ticket 流转图
```mermaid
graph TD
A[用户登录] -->|成功| B[CAS Server 颁发 TGT]
B -->|写入 Cookie| C[TGT 存在 CAS Server]
C -->|SP 请求| D[生成 ST]
D -->|一次性验证| E[SP 后端验证 ST]
E -->|返回用户信息| F[SP 建立本地 Session]
A[用户在 CAS Server 登录] -->|成功| B[CAS Server 颁发 TGT]
B -->|TGT 以 Cookie 形式<br/>存在 CAS 域名下| C[用户浏览器持有 TGT Cookie]
C -->|用户访问 SP 1| D[CAS 生成 ST-1]
C -->|用户访问 SP 2| E[CAS 生成 ST-2]
D -->|SP 1 后端验证 ST-1| F[SP 1 建立本地 Session]
E -->|SP 2 后端验证 ST-2| G[SP 2 建立本地 Session]
```
## 关键接口
**TGT vs ST 的关系**:TGT 是「总的登录状态」(在 CAS Server 端),ST 是「给某个 SP 的一次性凭证」。用户登录一次获得一个 TGT,之后访问每个 SP 都会从 TGT 派生出一个新的 ST。
CAS 协议定义的核心端点:
## CAS 2.0 vs 3.0
| 端点 | 说明 |
| 版本 | 区别 |
|------|------|
| `/login` | 登录端点,支持 `service` 参数 |
| `/logout` | 登出端点,支持 `service` 参数回跳 |
| `/serviceValidate` | ST 验证端点(CAS 2.0) |
| `/p3/serviceValidate` | ST 验证端点(CAS 3.0,返回更多用户属性) |
| `/proxyValidate` | PT 验证端点 |
| `/proxy` | 获取 PGT |
| CAS 2.0 | `/serviceValidate` 端点,只返回用户名 |
| CAS 3.0 | `/p3/serviceValidate` 端点,除了用户名还返回用户属性(邮箱、角色等) |
### Ticket 验证响应示例
**CAS 3.0 响应示例:**
**CAS 2.0:**
```xml
<cas:serviceResponse>
<cas:authenticationSuccess>
<cas:user>zhangsan</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
```
**CAS 3.0(支持属性释放):**
```xml
<cas:serviceResponse>
<cas:authenticationSuccess>
<cas:user>zhangsan</cas:user>
<cas:user>zhangsan</cas:user> <!-- 用户名 -->
<cas:attributes>
<cas:email>zhangsan@example.com</cas:email>
<cas:displayName>张三</cas:displayName>
<cas:department>engineering</cas:department>
<cas:email>zhangsan@example.com</cas:email> <!-- 邮箱 -->
<cas:displayName>张三</cas:displayName> <!-- 显示名 -->
<cas:department>engineering</cas:department> <!-- 部门 -->
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
```
> 接入时优先使用 CAS 3.0(`/p3/serviceValidate`),能拿到更多用户信息。
## Go 接入示例
CAS 接入相对简单,核心逻辑就是拦截请求 + 验证 Ticket:
CAS 接入的核心逻辑:拦截请求 → 重定向登录 → 验证 Ticket → 建立 Session。
```go
// CAS Client 核心逻辑
func CASServerURL = "https://cas.example.com"
func ServiceURL = "https://app.example.com"
const (
casServerURL = "https://cas.example.com"
serviceURL = "https://app.example.com"
)
// CASMiddleware 拦截所有请求,未登录则重定向到 CAS
func CASMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 检查本地 Session
if session, _ := store.Get(r, "session"); session.Values["user"] != nil {
// 1. 已经登录过(本地 Session 存在)→ 直接放行
session, _ := store.Get(r, "session")
if user, ok := session.Values["user"].(string); ok && user != "" {
next.ServeHTTP(w, r)
return
}
// 2. 检查 URL 中的 Ticket
// 2. 检查 URL 中是否有 Ticket
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
// 3. 没有 Ticket → 重定向到 CAS
loginURL := CASServerURL + "/login?service=" + url.QueryEscape(ServiceURL+r.URL.Path)
// 没有 Ticket → 重定向到 CAS 登录页
// service 参数告诉 CAS 登录完成后跳回哪里
loginURL := fmt.Sprintf("%s/login?service=%s",
casServerURL,
url.QueryEscape(serviceURL+r.URL.Path),
)
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
// 4. 后端验证 Ticket
// 3. 有 Ticket → 后端去 CAS 验证(这一步是后端直连,不经过浏览器)
validateURL := fmt.Sprintf(
"%s/p3/serviceValidate?ticket=%s&service=%s",
CASServerURL, ticket, url.QueryEscape(ServiceURL),
casServerURL,
ticket,
url.QueryEscape(serviceURL+r.URL.Path),
)
resp, err := http.Get(validateURL)
if err != nil {
http.Error(w, "CAS validation failed", 500)
http.Error(w, "CAS validation failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// 5. 解析 XML 响应,提取用户名
// ... 解析 logic ...
// 4. 解析 CAS 返回的 XML,提取用户名
body, _ := io.ReadAll(resp.Body)
// 简化的 XML 解析(生产环境建议用 encoding/xml)
var result struct {
AuthenticationSuccess struct {
User string `xml:"user"`
} `xml:"authenticationSuccess"`
}
if err := xml.Unmarshal(body, &result); err != nil ||
result.AuthenticationSuccess.User == "" {
http.Error(w, "CAS authentication failed", http.StatusUnauthorized)
return
}
// 6. 建立本地 Session
session, _ := store.Get(r, "session")
session.Values["user"] = parsedUser
// 5. 建立本地 Session(之后就不用每次都去 CAS 验证了)
session.Values["user"] = result.AuthenticationSuccess.User
session.Save(r, w)
// 7. 重定向去掉 Ticket 参数
cleanURL := strings.Split(r.URL.String(), "?")[0]
// 6. 重定向到去掉 ticket 参数的原始 URL
// 这样用户刷新页面不会重复验证 Ticket(Ticket 已经失效了)
cleanURL := r.URL.Path
if r.URL.RawQuery != "" {
// 保留非 ticket 的查询参数
q := r.URL.Query()
q.Del("ticket")
if encoded := q.Encode(); encoded != "" {
cleanURL += "?" + encoded
}
}
http.Redirect(w, r, cleanURL, http.StatusFound)
})
}
@@ -157,23 +190,33 @@ func CASMiddleware(next http.Handler) http.Handler {
## 常见陷阱与最佳实践
**Ticket 不能重复验证**
- ST 验证一次后即失效,不要缓存 Ticket 验证结果
- 用户刷新页面时 Ticket 已经失效,应该走 Session 而不是重新验证
### Ticket 不能重复验证
**service 参数必须严格匹配**
- CAS Server 会校验 service 参数是否在注册列表中
- 不要拼接用户可控的内容到 service 参数
Service Ticket 验证一次就失效了。如果用户点完登录后按了 F5 刷新页面,URL 里的 ST 已经用过了,再去验证会失败。
**登出的局限性**
- CAS 的 `/logout` 只能清除 TGT(CAS Server 端的登录态)
- 各 SP 的本地 Session 需要各自处理,CAS 通过回调通知 SP(Back-channel 或 Front-channel)
- 实际部署中,很多 SP 不实现登出回调,导致用户以为登出了但 SP Session 仍有效
**解决**:步骤 6 中重定向到去掉 ticket 参数的 URL。用户刷新时 URL 里没有 ticket,走的是本地 Session(步骤 1),不会重复验证。
**CAS vs OIDC 的选择**
- 如果你的系统只需要内网 Web 应用 SSO,CAS 足够
- 如果需要支持移动端、SPA、第三方集成,直接上 OIDC
- 很多现代 CAS Server(如 Apereo CAS 6.x)同时支持 CAS 协议和 OIDC/SAML
### service 参数必须严格匹配
> [!tip] Apereo CAS Server 的现代化
> Apereo CAS Server 6.x+ 已经不仅仅是 CAS 协议服务器了。它同时支持 CAS / OIDC / SAML / REST API,可以作为统一身份网关使用。如果你需要一个开源自建的 SSO 平台,值得考虑。
CAS Server 会校验 `service` 参数是否在预注册的白名单中。不要把用户可控的内容拼到 `service` 参数里。
### 必须用 HTTPS
CAS 的 Ticket 是通过 URL 传递的(`?ticket=ST-xxx`)。如果用 HTTP,网络上的任何人都能看到 Ticket 并冒充用户。**CAS 必须全程 HTTPS。**
### 登出的局限性
CAS 的 `/logout` 端点会清除 TGT(CAS Server 端的登录态),但它没法直接清除各个 SP 的本地 Session。
CAS 通过两种方式通知 SP:
- **Front-channel(前台通知)**:浏览器跳转到各个 SP 的登出 URL(通过 iframe 或重定向)
- **Back-channel(后台通知)**:CAS Server 直接调用 SP 的登出接口(服务器对服务器)
实际部署中,很多 SP 不实现登出回调。**务实的做法**:先清除本地 Session,CAS 端的 TGT 也清除,用户下次访问任何 SP 都会重新走登录流程。
### TGT 过期后的体验
TGT 通常有较长的有效期(几小时到几天,取决于配置)。TGT 过期后,用户访问 SP 时会被重定向到 CAS 登录页重新输入密码。如果 CAS Server 配置了「记住我」功能,可能只需要重新点击确认而不需要输入密码。
> [!tip] 现代 CAS Server 的进化
> Apereo CAS Server 6.x+ 已经不只是一个 CAS 协议服务器了。它同时支持 **CAS / OIDC / SAML** 协议,可以作为统一身份网关——新系统用 OIDC 接入,老系统用 CAS 接入,一套 CAS Server 全搞定。如果你需要开源自建 SSO 平台,Apereo CAS 值得考虑。
+107 -86
View File
@@ -5,124 +5,145 @@ create time: 2026-07-13 10:03
# SSO 协议对比与选型指南
## 概述
## TL;DR
本文横向对比 CAS、SAML 2.0、OAuth 2.0、OIDC 四种主流 SSO 协议,帮助你在实际项目中做出合理的选型决策。
> **不知道选什么?选 OIDC。** 它是当前最通用的 SSO 协议,支持 Web、移动端、SPA,生态最好,学习资源最丰富。
>
> 只有在以下情况才考虑其他协议:
> - 客户要求 SAML 对接 → 用 **SAML 2.0**
> - 只有内部 Web 系统、已有 CAS 基础设施 → 用 **CAS**
> - 不需要认证,只需要 API 授权 → 用 **OAuth 2.0**
## 横向对比总览
## 四种协议一览
| 维度 | CAS | SAML 2.0 | OAuth 2.0 | OIDC |
|------|-----|----------|-----------|------|
| **诞生年份** | 2000 | 2005 | 2012 | 2014 |
| **数据格式** | 自定义 XML | XML | JSON | JSON (JWT) |
| **传输层** | HTTP 重定向 | HTTP 重定向 + POST | HTTPS API | HTTPS API |
| **核心目的** | 认证 (SSO) | 认证 (SSO) | 授权 | 认证 + 授权 |
| **Token 类型** | Ticket (ST/TGT) | Assertion | access_token | id_token + access_token |
| **移动端支持** | 差 | 差 | 好 | 优秀 |
| **SPA 支持** | 差 | 差 | 好 (PKCE) | 优秀 (PKCE) |
| **实现复杂度** | 低 | 高 | 中 | 中 |
| **生态丰富度** | 一般 | 丰富 (企业级) | 极丰富 | 极丰富 |
| **主要用户** | 高企内部系统 | 企业 B2B SaaS | 第三方登录 | 现代 Web/移动应用 |
| **一句话定位** | 内网 Web SSO | 企业级联合认证 | API 授权框架 | 现代认证 + 授权 |
| **诞生** | 2000(耶鲁大学) | 2005(OASIS 标准) | 2012(IETF RFC 6749) | 2014(OpenID Foundation) |
| **数据格式** | 自定义 XML | 重量级 XML | JSON | JSON(JWT) |
| **凭证类型** | Ticket(一次性票据) | Assertion(签名的 XML 文档) | access_token | id_token + access_token |
| **移动端 / SPA** | ❌ 不适合 | ❌ 不适合 | ✅ 好(PKCE) | ✅ 最佳(PKCE) |
| **实现复杂度** | ⭐ 低 | ⭐⭐⭐ 高 | ⭐⭐ 中 | ⭐⭐ 中 |
| **Go 生态** | 一般 | 一般 | 优秀(标准库) | 优秀(go-oidc) |
| **调试难度** | 低 | 高(XML 坑多) | 中 | 中 |
## 各协议适用场景
## 什么时候该选哪个?
### 选择 CAS 的场景
### OIDC — 默认选择
- 企业内部多个传统 Web 系统需要统一登录
- 已有 Apereo CAS Server 基础设施
- 团队对 OAuth/JWT 不熟悉,需要快速落地
- 不涉及移动端或第三方接入
- ✅ 新项目、没有特殊约束
- ✅ 需要支持移动端、SPA、微服务
- ✅ 要对接 Keycloak、Auth0、Azure AD 等现代 IdP
- ✅ 需要标准化的用户信息接口
### 选择 SAML 2.0 的场景
### SAML 2.0 — 企业客户要求时
- 与企业客户的 IdP(AD FS、Okta)对接
- 已有 AD/LDAP 用户体系,需要对外暴露 SSO
- B2B SaaS 产品,客户要求 SAML 集成
- 合规要求(如 FedRAMP、SOC 2)强制使用 SAML
- ✅ 客户的 IT 部门只提供 SAML IdP(AD FS、Okta)
- ✅ B2B SaaS 产品,客户要求 SAML 集成
- ✅ 合规要求(FedRAMP、SOC 2)强制 SAML
- ⚠️ 可以同时支持 OIDC + SAML(Keycloak 两者都支持)
### 选择 OAuth 2.0 的场景
### CAS — 遗留系统 / 简单场景
- 第三方应用需要有限度地访问你的 API
- 「用微信/GitHub 登录」这类需求
- 纯 API 服务的访问控制(客户端凭证模式)
- 服务间的 M2M 认证
- ✅ 企业内网几个传统 Web 系统需要统一登录
- ✅ 已有 Apereo CAS Server 基础设施
- ✅ 团队不熟悉 OAuth/JWT,需要快速落地
- ❌ 不适合移动端、SPA、第三方接入
### 选择 OIDC 的场景
### OAuth 2.0 — 只需要授权,不需要认证
- **新项目的首选**——现代、通用、生态好
- SPA / 移动端 / 微服务架构
- 需要标准化的用户信息接口
- 需要和主流 IdP(Keycloak、Auth0、Azure AD)对接
## 技术栈对比
### 数据格式
| 格式 | 优点 | 缺点 |
|------|------|------|
| XML (SAML) | 严谨、标准完善 | 冗长、解析慢、安全风险多 |
| JSON (OAuth/OIDC) | 轻量、开发者友好 | 规范相对灵活,需自行约束 |
| JWT (OIDC ID Token) | 自包含、可离线验证 | 无法即时撤销、Payload 可见 |
### 签名与加密
| 协议 | 签名方式 | 加密支持 |
|------|----------|----------|
| CAS | 可选 (HMAC) | 不支持 |
| SAML | XML Digital Signature(RSA/EC) | XML Encryption(RSA/AES) |
| OAuth 2.0 | 无标准(靠 HTTPS) | 无标准 |
| OIDC | JWT Signature(RS256/ES256) | JWE(可选) |
> [!info] 为什么 OIDC 通常只签名不加密?
> JWT 的 Payload 通常是用户 ID、邮箱等非敏感信息。签名保证完整性(不被篡改),HTTPS 保证传输安全。如果需要隐藏 Payload,使用 JWE 加密。
- ✅ 第三方应用需要访问你的 API(如「用微信登录」)
- ✅ 服务间调用(M2M),用客户端凭证模式
- ⚠️ 如果需要知道「用户是谁」,请用 OIDC(OAuth 2.0 不提供用户身份信息)
## 选型决策树
```mermaid
graph TD
A[需要 SSO] --> B{有移动端/SPA?}
B -->|是| C[OIDC]
B -->|否| D{对接企业客户 IdP?}
D -->|是| E{客户要求?}
A[需要 SSO] --> B{有移动端或 SPA?}
B -->|是| C[OIDC ✅]
B -->|否| D{需要对接企业客户 IdP?}
D -->|是| E{客户要求什么协议?}
E -->|SAML| F[SAML 2.0]
E -->|OIDC| C
E -->|无要求| C
D -->|否| G{内部系统?}
G -->|是| H{已有基础设施?}
H -->|CAS Server| I[CAS]
H -->|Keycloak| C
H -->|无| C
G -->|否| J{第三方授权?}
E -->|OIDC 或无要求| C
D -->|否| G{只是内部 Web 系统?}
G -->|是| H{已有 CAS 基础设施?}
H -->|有| I[CAS]
H -->|没有| C
G -->|否| J{第三方 API 授权?}
J -->|是| K[OAuth 2.0]
J -->|否| C
```
> [!tip] 默认选 OIDC
> 如果你没有强烈的理由选择其他协议,**OIDC 是最安全的默认选择**。它覆盖了 SSO + 授权的完整场景,生态支持最好,学习资源最丰富。
## 数据格式与签名
## 接入成本评估
| 格式 | 说明 | 优点 | 缺点 |
|------|------|------|------|
| **XML**(SAML/CAS) | 2000 年代企业标准 | 工具链成熟、标准严谨 | 冗长、解析慢、安全坑多(如 XML Signature Wrapping) |
| **JSON**(OAuth 2.0) | 无 Token 结构约束 | 轻量、开发者友好 | 需要自行定义 Token 格式 |
| **JWT**(OIDC) | JSON + 签名 | 自包含、可离线验证、标准统一 | Payload 可见(非加密)、无法即时撤销 |
**签名能力对比:**
| 协议 | 签名 | 加密 | 说明 |
|------|------|------|------|
| CAS | 可选(HMAC) | ❌ | 简单场景够用 |
| SAML | ✅ XML Digital Signature | ✅ XML Encryption | 功能最全,但也最复杂 |
| OAuth 2.0 | ❌ 无标准定义 | ❌ | 靠 HTTPS 保护传输安全 |
| OIDC | ✅ JWT Signature(RS256/ES256) | 可选(JWE) | 签名是标配,加密是可选 |
> [!info] 为什么 OIDC 通常只签名不加密?
> JWT 的 Payload 通常是用户 ID、邮箱等非敏感信息。签名保证「内容没被篡改」,HTTPS 保证「传输过程不被窃听」。两者结合已经足够安全。如果你确实需要隐藏 Payload(比如把 JWT 放在 URL 里),可以用 JWE 加密。
## 接入成本对比
| 维度 | CAS | SAML | OAuth 2.0 | OIDC |
|------|-----|------|-----------|------|
| **Go 库成熟度** | 一般 | 一般 | 优秀 | 优秀 |
| **调试难度** | 低 | 高(XML 解析坑多) | 中 | 中 |
| **文档质量** | 一般 | 规范详尽但晦涩 | 优秀 | 优秀 |
| **测试工具** | 少 | SAML Tracer 等 | OAuth Proxy | OIDC Debugger |
| **预计接入工时** | 1-3 天 | 3-7 天 | 1-3 天 | 1-3 天 |
| **首次接入工时**(有经验) | 1-2 天 | 3-5 天 | 1-3 天 | 1-3 天 |
| **首次接入工时**(新手) | 2-3 天 | 5-10 天 | 2-4 天 | 2-4 天 |
| **主要耗时点** | 逻辑简单,主要是 XML 解析 | XML 解析、签名验证、配置交换 | 理解 Token 生命周期 | 理解 JWT + 流程 |
| **调试工具** | 浏览器开发者工具 | SAML Tracer(Firefox 插件) | OAuth Proxy、Postman | OIDC Debugger、jwt.io |
| **Go 库推荐** | 自行实现(逻辑简单) | crewjam/saml | golang.org/x/oauth2(标准库) | coreos/go-oidc |
> [!warning] 工时估算的前提
> 以上工时假设你已经有了可用的 IdP(如 Keycloak)。如果连 IdP 都要自己搭建和配置,额外增加 1-3 天。
## 混合方案:多协议并存
实际项目中,你很可能不是只用一种协议。常见的混合方案:
**Keycloak 作为统一网关:**
- 新系统用 OIDC 接入
- 老系统用 SAML 接入
- 内部遗留系统用 CAS 接入(Apereo CAS Server 同时支持三种协议)
- 所有系统共享同一套用户目录(LDAP/AD)
```mermaid
graph TD
LDAP[LDAP / AD 用户目录] --> IdP[统一身份网关<br/>Keycloak / Apereo CAS]
IdP -->|OIDC| A[新 Web 应用]
IdP -->|OIDC| B[移动端 App]
IdP -->|SAML| C[企业客户 SaaS]
IdP -->|CAS| D[内部遗留系统]
```
这种方案的好处是:用户目录统一管理,各系统按自己的节奏迁移协议,不需要一刀切。
## 推荐开源 IdP
| 产品 | 支持协议 | 特点 | 适用规模 |
|------|----------|------|----------|
| **Keycloak** | OIDC, SAML | 功能全面,Red Hat 维护 | 中大型 |
| **Auth0** | OIDC, SAML | SaaS 托管,上手快 | 中小型 |
| **Casdoor** | OIDC, SAML, CAS | Go 实现,中文社区 | 中小型 |
| **Apereo CAS** | CAS, OIDC, SAML | 高校/企业经典选择 | 中大型 |
| **Authentik** | OIDC, SAML | Python,现代化 UI | 中小型 |
| **Logto** | OIDC | 开源 Auth0 替代 | 中小型 |
| 产品 | 协议支持 | 语言 | 特点 | 推荐场景 |
|------|----------|------|------|----------|
| **Keycloak** | OIDC、SAML | Java | 功能最全面,Red Hat 维护,社区活跃 | 中大型自建 SSO(首选) |
| **Auth0** | OIDC、SAML | SaaS 托管 | 零运维,上手最快 | 中小型、快速上线 |
| **Casdoor** | OIDC、SAML、CAS | Go | 轻量,中文文档友好 | 中小型、Go 技术栈 |
| **Apereo CAS** | CAS、OIDC、SAML | Java | 高校/企业经典方案 | 有 CAS 遗留的场景 |
| **Logto** | OIDC | TypeScript | 开源 Auth0 替代,现代化 UI | 中小型 |
> [!tip] Keycloak 是自建 SSO 的首选
> 如果要自建 SSO 平台,Keycloak 是当前综合评分最高的开源方案。支持 OIDC + SAML,管理界面完善,社区活跃。唯一的缺点是基于 Java,内存占用较大。
> [!tip] IdP 选型建议
> - **没有特殊要求** → Keycloak(功能全面、免费、社区大)
> - **不想运维** → Auth0(SaaS 托管,有免费额度)
> - **Go 技术栈、想要轻量** → Casdoor
> - **已有 CAS 基础设施** → Apereo CAS Server 6.x+(同时支持 OIDC/SAML)
## 关联笔记
+172 -85
View File
@@ -7,136 +7,223 @@ create time: 2026-07-13 10:03
## 概述
OAuth 2.0 是一个**授权框架**(RFC 6749),设计初衷是让第三方应用在不获取用户密码的前提下,有限度地访问用户的资源。授权码模式(Authorization Code Grant)是最安全、最常用的流程,也是构建 SSO 的基础。
假设你在开发一个「周报助手」应用,需要读取用户的 Google 日历来自动安排会议。你不可能让用户把 Google 密码告诉你——这既不安全,也不合理。你需要的只是**有限度地访问用户的日历数据**。
> [!info] OAuth 2.0 ≠ 认证协议
> OAuth 2.0 解决的是「**授权**」—— 让第三方拿走你的部分权限。它本身不提供用户身份信息,这也是为什么后来需要 OIDC 来补上这一环。
**OAuth 2.0(RFC 6749)** 就是解决这个问题的框架:让用户**授权**第三方应用访问自己的部分资源,而不需要交出密码。
## 核心流程
> [!info] 认证(Authentication)vs 授权(Authorization)
> 这是两个经常混淆的概念:
> - **认证**:证明「你是谁」→ 比如出示身份证,回答「我是张三」
> - **授权**:决定「你能干什么」→ 比如张三可以查看日历,但不能删除日历
>
> **OAuth 2.0 只解决授权**。它能拿到你的日历数据,但不知道(也不关心)登录的用户是张三还是李四。后来 OIDC 在 OAuth 2.0 之上补了认证,才实现了完整的 SSO。
## 核心角色
OAuth 2.0 定义了四个角色(比 SSO 主文档多了一个 Resource Server):
| 角色 | 说明 | 生活类比 |
|------|------|----------|
| **Resource Owner** | 资源的拥有者,就是用户本人 | 房子的主人 |
| **Client** | 请求访问资源的第三方应用 | 要借住的朋友 |
| **Authorization Server** | 授权服务器,负责认证用户、颁发 Token | 物业管理处 |
| **Resource Server** | 存放资源的 API 服务 | 你的房子 |
> [!tip] 为什么 Authorization Server 和 Resource Server 要分开?
> 在小型系统中它们通常是同一个服务。但在大型架构中,认证是一个独立的基础设施(如 Keycloak),业务 API 可能有几十个。分离后,所有 API 共享同一个认证中心。
## 授权码模式完整流程
这是 OAuth 2.0 中最安全、最常用的模式。我们用「周报助手访问 Google 日历」这个场景来走一遍:
```mermaid
sequenceDiagram
participant U as 用户 (浏览器)
participant App as Client App (SP)
participant Auth as Authorization Server (IdP)
participant Res as Resource Server
participant App as 周报助手 (Client)
participant Google as Google 授权服务器
participant Cal as Google 日历 API
U->>App: 1. 点击「登录」
App->>U: 2. 302 重定向到 Auth
Note right of App: redirect_uri, scope, state, client_id
U->>Auth: 3. 登录 + 授权确认
Auth->>U: 4. 302 回调 redirect_uri
Note left of Auth: ?code=AUTH_CODE&state=xxx
U->>App: 5. 携带 code 回到 App
App->>Auth: 6. POST /token(后端请求)
Note right of App: code, client_id, client_secret, redirect_uri
Auth-->>App: 7. 返回 access_token
App->>Res: 8. 用 access_token 请求资源
Res-->>App: 9. 返回受保护数据
U->>App: 1. 点击「关联 Google 日历」
App->>U: 2. 浏览器跳转到 Google 登录页
Note right of App: URL 携带 client_id、scope、<br/>redirect_uri、state 参数
U->>Google: 3. 登录 Google 账号
Google->>U: 4. 展示授权页面:「周报助手想要读取你的日历」
U->>Google: 5. 点击「允许」
Google->>U: 6. 浏览器跳回周报助手的回调地址
Note left of Google: URL 携带一次性授权码 code 和 state
U->>App: 7. 周报助手收到 code
App->>Google: 8. 后端用 code + client_secret 换 Token
Note right of App: 这一步是后端直连 Google,<br/>不经过浏览器
Google-->>App: 9. 返回 access_token(+ 可选的 refresh_token)
App->>Cal: 10. 用 access_token 请求日历数据
Cal-->>App: 11. 返回日历事件
```
关键点:**步骤 2-5 走浏览器重定向,步骤 6-7 走后端 HTTPS 直连**。code 只能用一次,且必须在后端交换,避免 token 暴露在前端。
**为什么这么绕?关键设计:**
## 四种授权模式对比
1. **步骤 2-7 走浏览器跳转**(用户能看到)—— 让用户亲自在 Google 页面登录和授权,密码不会经过第三方应用
2. **步骤 8-9 走后端直连**(用户看不到)—— `code` 换 `token` 的过程在后端完成,`token` 不会暴露在浏览器地址栏中
3. **`code` 只能用一次**,有效期通常 < 10 分钟,即使被截获也无法使用
| 模式 | 流程 | 安全性 | 适用场景 |
|------|------|--------|----------|
| **授权码模式** | 重定向 + 后端换 Token | 最高 | Web 后端应用、SSO |
| 授权码 + PKCE | 授权码 + 代码验证码 | 高 | SPA / 移动端(推荐) |
| 客户端凭证模式 | 直接换 Token | 中 | 服务间调用(M2M) |
| ~~隐式模式~~ | 直接返回 Token | 低 | **已弃用**,仅遗留系统 |
### 关键参数说明
> [!warning] 隐式模式已被废弃
> RFC 9700 明确不推荐使用隐式模式。Token 暴露在 URL Fragment 中,容易被中间人和浏览器历史记录泄露。SPA 应使用 **PKCE** 模式替代。
步骤 2 的跳转 URL 大致长这样:
## 核心概念速查
### 四个关键角色
| 角色 | 职责 | 示例 |
|------|------|------|
| Resource Owner | 资源拥有者(用户) | 登录的你 |
| Client | 第三方应用 | 公司的 Web 系统 |
| Authorization Server | 授权服务器 | Keycloak、Auth0 |
| Resource Server | 资源服务器 | 你的 API 服务 |
### 关键参数
```
https://accounts.google.com/o/oauth2/v2/auth
?client_id=abc123 ← 你在 Google 注册的应用 ID
&redirect_uri=https://app.example.com/callback ← 回调地址(注册时填好的)
&scope=https://www.googleapis.com/auth/calendar.readonly ← 请求的权限范围
&state=xyz789 ← 防 CSRF 的随机字符串
&response_type=code ← 告诉 Google 我要授权码模式
```
| 参数 | 说明 |
|------|------|
| `client_id` | 应用注册时分配的标识 |
| `client_secret` | 应用密钥,仅后端持有 |
| `redirect_uri` | 回调地址,必须预注册 |
| `scope` | 请求的权限范围,如 `openid profile email` |
| `state` | 防 CSRF 的随机字符串,回调时原样返回 |
| `code` | 授权码,一次性,有效期通常 < 10 分钟 |
| `client_id` | 应用在授权服务器注册时获得的 ID,类似「应用的身份证号」 |
| `client_secret` | 应用密钥,只有后端知道,绝不能暴露到前端 |
| `redirect_uri` | 授权完成后浏览器跳回的地址,必须和注册时完全一致 |
| `scope` | 请求的权限范围,如「只读日历」「读写日历」。范围越小,用户越信任 |
| `state` | 随机字符串,用于防 CSRF 攻击。回调时 Google 会原样返回,你比对一下就知道是不是你发起的请求 |
| `code` | 一次性授权码,用来换 Token,用完即废 |
### Access Token vs Refresh Token
## Access Token vs Refresh Token
授权服务器通常返回两个 Token:
```mermaid
graph LR
A[Access Token] -->|有效期短 5-30min| B[访问 API]
C[Refresh Token] -->|有效期长 天/月| D[换取新 Access Token]
C -.->|轮换机制| C2[新 Refresh Token]
A[用户授权] --> B[授权服务器]
B -->|颁发| C[Access Token]
B -->|颁发| D[Refresh Token]
C -->|有效期短<br/>5-30 分钟| E[调用 API]
D -->|有效期长<br/>几天到几个月| F[换新的 Access Token]
F -.->|每次刷新,旧的 RT 作废| D
```
| Token | 用途 | 存储 | 有效期 |
|-------|------|------|--------|
| Access Token | 调用 API | 内存 / 安全 Cookie | 短(5-30 min) |
| Refresh Token | 刷新 Access Token | HttpOnly Cookie / 后端 | 长(天 ~ 月) |
| Token | 用途 | 有效期 | 存储建议 |
|-------|------|--------|----------|
| **Access Token** | 调用 API 时放在 Header 里 | 短(5-30 分钟) | 内存中,或 HttpOnly Cookie |
| **Refresh Token** | Access Token 过期后用来续期 | 长(天 ~ 月) | 后端存储,或 HttpOnly Secure Cookie |
> [!tip] Refresh Token Rotation
> 每次用 Refresh Token 换新 Token 时,同时颁发新的 Refresh Token 并使旧的失效。这样即使某个 Refresh Token 泄露,攻击者也只能用一次。
> [!tip] Refresh Token Rotation(轮换机制)
> 每次用 Refresh Token 换新 Token 时,授权服务器会同时颁发一个新的 Refresh Token,并使旧的失效。这样即使某个 Refresh Token 泄露,攻击者也只能用一次,第二次就会被发现。
## 四种授权模式对比
除了授权码模式,OAuth 2.0 还定义了其他几种模式。了解它们的适用场景:
| 模式 | 一句话解释 | 适用场景 | 安全性 |
|------|-----------|----------|--------|
| **授权码模式** | 用户授权 → 拿到一次性 code → 后端换 Token | Web 后端应用、SSO | 最高 |
| **授权码 + PKCE** | 在授权码基础上加一个动态验证码,防止 code 被截获 | SPA(前端单页应用)、移动端 | 高 |
| **客户端凭证** | 没有用户参与,应用直接用自己的身份换 Token | 服务间调用(M2M),如定时任务调 API | 中 |
| ~~隐式模式~~ | Token 直接返回到浏览器地址栏 | **已弃用**,仅遗留系统 | 低 |
> [!warning] 什么是 PKCE?
> PKCE(Proof Key for Code Exchange,发音 "pixy")解决的是 SPA 和移动端无法安全存储 `client_secret` 的问题。
>
> 原理:客户端先生成一个随机的 `code_verifier`,对其做哈希得到 `code_challenge`。授权请求时带上 `code_challenge`,换 Token 时带上原始的 `code_verifier`。授权服务器验证哈希是否匹配。即使攻击者截获了 `code`,没有 `code_verifier` 也换不到 Token。
>
> **新项目做 SPA/移动端,一律用 PKCE,不要用隐式模式。**
## Go 实现示例
使用 `golang.org/x/oauth2` 实现授权码流程的核心步骤:
使用标准库 `golang.org/x/oauth2` 实现完整的授权码流程:
```go
// 配置 OAuth2 客户端
import (
"context"
"crypto/rand"
"encoding/hex"
"golang.org/x/oauth2"
)
// 1. 配置 OAuth2 客户端(通常从环境变量或配置文件读取)
conf := &oauth2.Config{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
Scopes: []string{"openid", "profile", "email"},
// scope:请求哪些权限。这里请求读取用户基本信息
Scopes: []string{"profile", "email"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://idp.example.com/oauth/authorize",
TokenURL: "https://idp.example.com/oauth/token",
},
// 授权完成后,IdP 会把浏览器重定向到这个地址
RedirectURL: "https://app.example.com/callback",
}
// 1. 生成授权 URL(附带 state 防 CSRF)
state := generateRandomState() // 需要存入 session
url := conf.AuthCodeURL(state, oauth2.AccessTypeOffline)
// 2. 处理「登录」按钮点击 — 生成授权 URL 并跳转
func handleLogin(w http.ResponseWriter, r *http.Request) {
// 生成随机 state 防 CSRF,存入 session 以便回调时比对
state := generateRandomState()
session, _ := store.Get(r, "session")
session.Values["oauth_state"] = state
session.Save(r, w)
// 2. Callback 处理 — 用 code 换 Token
token, err := conf.Exchange(ctx, code)
if err != nil {
// 授权码过期或无效
log.Fatal(err)
// AccessTypeOffline 表示我们需要 Refresh Token(默认只给 Access Token)
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline)
http.Redirect(w, r, authURL, http.StatusFound)
}
// 3. 用 Token 请求用户信息
client := conf.Client(ctx, token)
resp, err := client.Get("https://idp.example.com/userinfo")
```
// 3. 处理回调 — 用 code 换 Token
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 校验 state,防止 CSRF 攻击
session, _ := store.Get(r, "session")
expectedState := session.Values["oauth_state"].(string)
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "state mismatch: possible CSRF attack", http.StatusBadRequest)
return
}
> `oauth2.AccessTypeOffline` 参数会请求颁发 Refresh Token。默认只发 Access Token。
// 用一次性授权码交换 Token(后端直连 IdP,不经过浏览器)
code := r.URL.Query().Get("code")
token, err := conf.Exchange(context.Background(), code)
if err != nil {
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
// token.AccessToken — 用来调 API 的短期令牌
// token.RefreshToken — 用来续期的长期令牌(如果请求了 AccessTypeOffline)
// token.Expiry — Access Token 的过期时间
// 用 Token 请求用户信息
client := conf.Client(context.Background(), token)
resp, _ := client.Get("https://idp.example.com/userinfo")
defer resp.Body.Close()
// ... 解析用户信息 ...
}
// 辅助函数:生成随机 state
func generateRandomState() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
```
## 常见陷阱与最佳实践
**CSRF 攻击**
- 必须使用 `state` 参数。生成随机值存入 session,回调时校验一致性
- 不用 `state` 就像进门不锁门
### CSRF 攻击:必须使用 state 参数
**Token 存储**
- 前端:不要存在 `localStorage`(XSS 可读取),用 HttpOnly Secure Cookie
- 后端:加密存储或存入 Session,不要写进数据库明文字段
**攻击场景**:攻击者构造一个恶意链接 `https://app.example.com/callback?code=ATTACKER_CODE`,诱导用户点击。如果没有 `state` 校验,你的应用会拿攻击者的 code 去换 Token,攻击者就能冒充用户。
**redirect_uri 必须严格匹配**
- 不支持通配符,不支持路径前缀匹配
- 生产环境不允许使用 `http://localhost`
**防御**:生成随机 `state` 存入 session,回调时比对一致性。不一致就拒绝。
**scope 最小化原则**
- 只请求你需要的权限,不要 `scope: "all"`
- 用户看到的授权页面 scope 越少,信任度越高
### Token 存储安全
| 位置 | 安全性 | 建议 |
|------|--------|------|
| `localStorage` | ❌ 任何 JS 代码(含 XSS)都能读取 | **不要用** |
| `sessionStorage` | ❌ 同上 | **不要用** |
| HttpOnly Cookie | ✅ JS 无法读取 | 推荐(后端设置) |
| 内存变量 | ✅ 页面刷新就丢失 | 适合短期使用 |
### redirect_uri 必须严格匹配
授权服务器对 `redirect_uri` 的校验是**精确匹配**——不支持通配符,不支持路径前缀。这是防止 Token 被发送到攻击者控制的地址。
### scope 最小化原则
只请求你真正需要的权限。用户在授权页面看到的权限列表越少,越愿意点击「允许」。`scope: "all"` 是最差的做法。
+142 -82
View File
@@ -7,71 +7,102 @@ create time: 2026-07-13 10:03
## 概述
OpenID Connect(OIDC)是构建在 OAuth 2.0 之上的**身份认证层**。如果说 OAuth 2.0 解决「你能访问什么」,OIDC 则补充了「你是谁」。它是当前最主流的 SSO 协议,几乎所有现代身份提供商(Keycloak、Auth0、Okta、Azure AD)都支持 OIDC。
在 [[sso/sso-oauth2|OAuth 2.0]] 中我们知道,授权码模式拿到的 `access_token` 只能调 API,但**不知道登录的用户是谁**——OAuth 2.0 只解决「授权」,不解决「认证」。
> [!info] 为什么需要 OIDC?
> OAuth 2.0 的授权码拿到的是 access_token,它可以调 API,但你不知道**登录的用户是谁**。应用只能再去调 `/userinfo` 接口,流程不统一。OIDC 通过引入 **ID Token** 直接告诉应用用户的身份。
**OIDC(OpenID Connect)** 在 OAuth 2.0 之上补了认证这一层。它增加了一个 **ID Token**,里面直接写着「登录的用户是张三,邮箱是 zhangsan@example.com」。
## OIDC vs OAuth 2.0 的区别
> [!info] 一个类比
> OAuth 2.0 像门禁系统——它验证你有权限进入大楼,但不记录你是谁。OIDC 在门禁旁边加了一个人脸识别摄像头——既验证权限,又知道是谁进来了。
OIDC 是当前最主流的 SSO 协议。Keycloak、Auth0、Okta、Azure AD、Google 等都支持。
## OIDC vs OAuth 2.0:到底多了什么?
| 维度 | OAuth 2.0 | OIDC |
|------|-----------|------|
| 本质 | 授权框架 | 认证 + 授权 |
| Token | access_token, refresh_token | **+ id_token (JWT)** |
| 用户信息 | 需要额外请求 /userinfo | ID Token 自带 + /userinfo 补充 |
| scope | 自定义 | 必须含 `openid`,标准 scope: `profile`, `email`, `address`, `phone` |
| 规范 | RFC 6749 | OpenID Connect Core 1.0 |
| 解决什么问题 | 授权(你能访问什么) | 认证 + 授权(你是谁 + 你能访问什么) |
| 返回的 Token | `access_token`, `refresh_token | **+ `id_token`**(用户身份信息) |
| 用户信息怎么拿 | 需要额外调 `/userinfo` 接口 | `id_token` 自带基本信息,`/userinfo` 补充详细信息 |
| scope 要求 | 自定义 | 必须包含 `openid`,标准 scope:`profile`、`email`、`address`、`phone` |
| 协议规范 | RFC 6749 | OpenID Connect Core 1.0 |
## 先理解 JWT
ID Token 是一个 **JWT(JSON Web Token)**,所以先搞清楚 JWT 是什么。
JWT 是一种**自包含的签名令牌**,由三部分用 `.` 连接组成:
```
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1In0.签名内容
├── Header (Base64) ──┤├── Payload (Base64) ───┤├─ Signature ─┤
```
**三部分分别是什么:**
| 部分 | 内容 | 说明 |
|------|------|------|
| **Header** | 算法和类型 | `{"alg": "RS256", "typ": "JWT"}` — 说明用什么算法签名 |
| **Payload** | 用户信息和元数据 | `{"sub": "user-123", "name": "张三"}` — **这是 Claim(声明)** |
| **Signature** | 签名 | 用 IdP 的私钥对前两部分签名,防止被篡改 |
> [!warning] JWT 的 Payload 不是加密的!
> Base64 只是编码,不是加密。任何人都能解码 Payload 看到内容。所以**不要在 JWT 里放密码、手机号等敏感信息**。JWT 的安全靠的是签名——你可以读,但改不了(改了签名就对不上)。
## 核心流程
OIDC 的授权码流程和 OAuth 2.0 几乎一致,区别在于 scope 包含 `openid`,返回值多了 `id_token`:
OIDC 的流程和 OAuth 2.0 授权码模式**几乎一样**,区别只有两点:
1. scope 必须包含 `openid`
2. Token 响应里多了 `id_token`
```mermaid
sequenceDiagram
participant U as 用户
participant App as Client (RP)
participant IdP as OpenID Provider
participant U as 用户 (浏览器)
participant App as 业务应用 (Client/RP)
participant IdP as 认证中心 (OpenID Provider)
U->>App: 1. 点击登录
App->>U: 2. 302 → IdP
Note right of App: scope=openid profile email
U->>IdP: 3. 认证 + 授权
IdP->>U: 4. 302 → redirect_uri
Note left of IdP: code=xxx & state=yyy
U->>App: 1. 点击「登录」
App->>U: 2. 浏览器跳转到 IdP
Note right of App: 比 OAuth 2.0 多了 scope=openid
U->>IdP: 3. 在 IdP 登录并授权
IdP->>U: 4. 浏览器跳回,携带 code
U->>App: 5. 回调
App->>IdP: 6. POST /token
IdP-->>App: 7. { access_token, id_token, refresh_token }
App->>App: 8. 验证 id_token 签名 + claims
App->>IdP: 6. 后端用 code 换 Token
IdP-->>App: 7. 返回 access_token + id_token + refresh_token
Note right of IdP: id_token 是新增的!<br/>它包含用户的身份信息
App->>App: 8. 验证 id_token 的签名和关键字段
App-->>U: 9. 登录成功
```
> [!tip] 术语对照
> OIDC 里把 Client 叫 **RP(Relying Party,依赖方)**,把授权服务器叫 **OpenID Provider(OP)**。本质和 OAuth 2.0 的 Client / Authorization Server 是一回事,只是换了个名字。本文统一用「应用」和「IdP」。
## ID Token 详解
ID Token 是一个 **JWT(JSON Web Token)**,包含用户身份信息,由 IdP 用私钥签名。
ID Token 是一个 JWT,包含了用户的身份信息。下面是实际的 ID Token 解码后的样子:
### 结构
### Header
JWT 由三部分组成:`Header.Payload.Signature`
**Header:**
```json
{
"alg": "RS256",
"alg": "RS256", // 签名算法:RSA + SHA256
"typ": "JWT",
"kid": "key-id-2026"
"kid": "key-2026-07" // 密钥 ID,用于查找对应的公钥
}
```
**Payload(Claims):**
### Payload(Claims)
**Claim(声明)** 就是一个键值对,表示关于用户的某个事实,比如「名字叫张三」就是一个 Claim。
```json
{
"iss": "https://idp.example.com",
"sub": "user-12345",
"aud": "your-client-id",
"exp": 1752374580,
"iat": 1752374280,
"nonce": "random-nonce-value",
"name": "张三",
"iss": "https://idp.example.com", // 签发者:谁签发了这个 Token
"sub": "user-12345", // 主题:用户的唯一标识
"aud": "your-client-id", // 受众:这个 Token 是给哪个应用的
"exp": 1752374580, // 过期时间(Unix 时间戳)
"iat": 1752374280, // 签发时间
"nonce": "abc123", // 防重放的随机值
"name": "张三", // 以下是用户信息
"email": "zhangsan@example.com",
"email_verified": true,
"picture": "https://cdn.example.com/avatar.jpg"
@@ -81,86 +112,115 @@ JWT 由三部分组成:`Header.Payload.Signature`
### 必须校验的 Claims
> [!danger] 不校验 = 门户大开
> 每一个 Claim 都有其安全意义,跳过任何一个都可能导致身份伪造。
> 收到 ID Token 后,必须逐项校验以下字段。跳过任何一个都可能被攻击者伪造身份。
| Claim | 校验规则 | 不校验的风险 |
| Claim | 怎么校验 | 不校验会怎样 |
|-------|----------|-------------|
| `iss` | 必须是你配置的 IdP 地址 | 接受恶意 IdP 签发的 Token |
| `aud` | 必须包含你的 `client_id` | Token 被别的应用盗用 |
| `exp` | 必须未过期 | 过期 Token 仍可使用 |
| `nonce` | 必须和请求时一致 | 重放攻击 |
| `signature` | 用 IdP 公钥验证 | Token 内容被篡改 |
| `iss` | 必须等于你配置的 IdP 地址 | 攻击者自己搭一个假 IdP 签发 Token |
| `aud` | 必须包含你的 `client_id` | Token 被别的应用偷去用 |
| `exp` | 当前时间必须 < `exp` | 过期 Token 仍能登录 |
| `nonce` | 必须和你发起请求时存的值一致 | 攻击者截获 Token 后重放使用 |
| `signature` | 用 IdP 的公钥验证签名 | Token 内容被篡改你发现不了 |
### 校验流程
### Go 校验示例
```go
// 使用 OIDC 库自动校验 ID Token
provider, err := oidc.NewProvider(ctx, "https://idp.example.com")
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
ctx := context.Background()
// 1. 通过 OIDC Discovery 自动获取 IdP 的所有端点和公钥
// 这会访问 https://idp.example.com/.well-known/openid-configuration
provider, err := oidc.NewProvider(ctx, "https://idp.example.com")
if err != nil {
log.Fatal("无法连接 IdP:", err)
}
// 2. 创建 ID Token 校验器(自动校验 iss、aud、签名)
verifier := provider.Verifier(&oidc.Config{
ClientID: "your-client-id",
})
// 解析并校验 ID Token(签名、iss、aud、exp、nonce 全自动校验)
// 3. 校验 rawIDToken(从 Token 响应中拿到的 id_token 字符串)
// 校验内容:签名有效性、iss、aud、exp
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Fatal("ID Token 校验失败:", err)
}
// 提取 Claims
// 4. 校验 nonce(手动校验,库不会帮你做)
if idToken.Nonce != expectedNonce {
log.Fatal("nonce 不匹配,可能是重放攻击")
}
// 5. 提取用户信息
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err != nil {
log.Fatal(err)
log.Fatal("解析 Claims 失败:", err)
}
fmt.Printf("登录用户:%s (%s)\n", claims.Name, claims.Email)
```
> 使用 `coreos/go-oidc` 库时,签名验证会自动从 IdP 的 **JWKS 端点**(`/.well-known/jwks.json`)拉取公钥并缓存。
> [!tip] 库是怎么自动拿到 IdP 公钥的?
> `oidc.NewProvider()` 会访问 IdP 的 Discovery 端点(`/.well-known/openid-configuration`),从中获取 `jwks_uri`(公钥端点),再拉取公钥并缓存。你只需要提供 IdP 的 URL,其他全自动。
## OIDC Discovery
## OIDC Discovery:自动发现 IdP 配置
每个 OIDC Provider 都暴露一个**发现端点**,让客户端自动获取所有配置:
每个 OIDC Provider 都暴露一个**发现端点**,让客户端自动获取所有配置,不需要手动填写各种 URL:
```
GET https://idp.example.com/.well-known/openid-configuration
```
返回:
```json
{
"issuer": "https://idp.example.com",
"authorization_endpoint": "https://idp.example.com/oauth/authorize",
"token_endpoint": "https://idp.example.com/oauth/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
"scopes_supported": ["openid", "profile", "email"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"]
"issuer": "https://idp.example.com", // IdP 身份标识
"authorization_endpoint": "https://idp.example.com/oauth/authorize", // 登录授权页面
"token_endpoint": "https://idp.example.com/oauth/token", // 换 Token 的接口
"userinfo_endpoint": "https://idp.example.com/userinfo", // 获取用户详情的接口
"jwks_uri": "https://idp.example.com/.well-known/jwks.json", // 公钥端点,用于验签
"scopes_supported": ["openid", "profile", "email"], // 支持的 scope
"response_types_supported": ["code"], // 支持的响应类型
"id_token_signing_alg_values_supported": ["RS256", "ES256"] // 支持的签名算法
}
```
> [!tip] Discovery 的好处
> 客户端只需要知道 IdP 的 URL,所有端点地址和算法支持都可以自动发现。这极大降低了接入成本。
> **客户端只需要知道 IdP 的 URL**,其他所有端点地址都通过 Discovery 自动获取。这是 OIDC 比 SAML 接入成本低的一个重要原因。
## 常见陷阱与最佳实践
**永远校验 ID Token 签名**
- 使用 IdP 公钥(通过 JWKS 获取)验证,不要只 Base64 解码就信了
- 公钥轮换时,库应自动从 JWKS 端点刷新
### 必须校验 ID Token 签名
**nonce 必须使用**
- 登录请求生成 nonce 存入 session,ID Token 校验时比对
- 防止 Token 被截获后重放
用 IdP 的公钥验证签名。`coreos/go-oidc` 会自动从 JWKS 端点获取和缓存公钥,不需要你手动管理。**不要只 Base64 解码就信任 Payload——签名没验证等于没验证。**
**UserInfo Endpoint 的定位**
- ID Token 已包含基本身份信息,UserInfo 用于获取额外数据
- 不要把敏感信息放在 ID Token 里(它可能被前端持有)
### nonce 是防重放的关键
**多租户场景的 `hd` 参数**
- Google OIDC 支持 `hd`(hosted domain)限制只允许特定域名登录
- 企业 SSO 场景务必限制,防止个人账号混入
流程:
1. 用户点击登录时,生成随机 `nonce` 存入 session
2. 把 `nonce` 放进授权请求
3. IdP 会把 `nonce` 原样写进 ID Token
4. 回调时比对 Token 中的 `nonce` 和 session 中的是否一致
**Key Rollover**
- IdP 会定期轮换签名密钥,客户端需要支持多 Key(通过 `kid` 匹配)
- 推荐使用自动 JWKS 刷新的库,不要硬编码公钥
如果攻击者截获了你的 ID Token,想在另一个浏览器重放,`nonce` 会对不上(因为 session 里存的不一样)。
### 不要在 ID Token 里放敏感信息
ID Token 可能被前端 JavaScript 直接读取(取决于你的架构)。密码、手机号、身份证号等不要放进去。需要这些信息时,用 `access_token` 调 `/userinfo` 接口获取。
### 密钥轮换(Key Rollover)
IdP 会定期更换签名密钥。客户端库需要通过 `kid`(Key ID)匹配正确的公钥。`coreos/go-oidc` 会自动处理,但**不要硬编码公钥**。
### Google 的 `hd` 参数(Google 专属)
> [!warning] 这是 Google 的扩展参数,不是 OIDC 标准
> Google OIDC 支持 `hd`(hosted domain)参数,限制只允许特定域名(如 `company.com`)的账号登录。企业 SSO 场景务必加上,否则用户可能用个人 Gmail 登录。
+173 -115
View File
@@ -7,186 +7,244 @@ create time: 2026-07-13 10:03
## 概述
SAML(Security Assertion Markup Language)2.0 是基于 XML 的**联合身份认证标准**,由 OASIS 于 2005 年发布。它是企业级 SSO 的事实标准,广泛用于 SaaS 应用集成(如 AWS、Salesforce、Workday 等),尤其在已有 LDAP/AD 的企业环境中几乎是必选项。
假设你的公司采购了一个 SaaS 项目管理工具,员工需要用公司账号登录。SaaS 厂商不可能直接连到你公司的用户数据库,那怎么办?
> [!info] SAML vs OIDC 的市场定位
> SAML 是企业时代的产物——XML 繁重但可靠,适合 B2B 场景。OIDC 是云原生时代的产物——JSON 轻量灵活,适合 B2C 和移动端。两者并存,不是替代关系。
**SAML(Security Assertion Markup Language,安全断言标记语言)2.0** 就是解决这个问题的协议:你公司的认证中心(IdP)给 SaaS 系统(SP)签发一份**带签名的「身份证明」**,SaaS 系统验证签名后就信任这份证明。
## 核心概念
> [!info] 什么是「联合身份认证」?
> **联合(Federation)** 的意思是:多个独立的组织互相约定「我信任你认证过的用户」。就像你用大学学生证去图书馆——图书馆不是大学开的,但它认大学发的证件。SAML 就是这个「证件」的标准格式。
>
> SAML 诞生于 2005 年,当时 JSON 还不流行,企业软件的标配是 XML。所以 SAML 天然使用 XML 格式——虽然冗长,但标准成熟、工具链完善。这正是 SAML 在企业 B2B 场景中仍然是主流的原因。
### 角色
## 三个核心角色
| 角色 | 全称 | 说明 |
| 角色 | 全称 | 说明 | 类比 |
|------|------|------|------|
| **IdP** | Identity Provider | 身份提供商,负责认证用户 | 大学校园卡中心 |
| **SP** | Service Provider | 业务应用,依赖 IdP 做认证 | 图书馆 |
| **Principal** | End User | 终端用户 | 拿学生证借书的你 |
## Assertion(断言):SAML 的核心
SAML 的一切围绕 **Assertion(断言)** 展开。断言就是 IdP 签发的一份 XML 格式的「身份证明文件」,相当于一个**带公章的工作证**:
> 「我是认证中心(Issuer),我证明这个人(Subject)叫张三(NameID),他的邮箱是 zhangsan@example.com(Attributes),认证时间是 2026-07-13(AuthnStatement),这份证明有效期 5 分钟(Conditions),只对图书馆有效(AudienceRestriction)。」
断言里的核心字段:
| 字段 | 说明 | 类比 |
|------|------|------|
| **IdP** | Identity Provider | 身份提供商,如 AD FS、Okta、OneLogin |
| **SP** | Service Provider | 业务应用,依赖 IdP 做认证 |
| **Principal** | End User | 发起 SSO 的终端用户 |
| `Issuer` | 谁签发的(IdP 地址) | 公章上的单位名 |
| `Subject` | 被认证的用户 | 工作证上的姓名 |
| `Conditions` | 有效期、只允许谁用 | 证件有效期 + 使用范围 |
| `AuthnStatement` | 认证方式和时间 | 「通过密码认证,时间 14:03」 |
| `AttributeStatement` | 用户属性(角色、邮箱等) | 部门、职位等附加信息 |
### 关键数据结构
## 核心流程(SP 发起)
**Assertion(断言)** — SAML 的核心数据单元,包含:
| 字段 | 说明 |
|------|------|
| `Issuer` | 断言签发者(IdP) |
| `Subject` | 被认证的用户 |
| `Conditions` | 有效期、受众限制 |
| `AuthnStatement` | 认证方式、时间 |
| `AttributeStatement` | 用户属性(角色、邮箱等) |
## 核心流程(SP-Initiated SSO)
SP 发起的 SSO 是最常见的流程:
最常见的是 **SP-Initiated** 流程——用户先访问 SP,SP 再把用户送到 IdP 登录:
```mermaid
sequenceDiagram
participant U as 用户
participant SP as Service Provider
participant IdP as Identity Provider
participant U as 用户 (浏览器)
participant SP as 业务应用 (SP)
participant IdP as 认证中心 (IdP)
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,跳转目标页
SP->>U: 2. 「你还没登录,去认证中心吧」<br/>生成 AuthnRequest,浏览器 302 跳转
Note right of SP: AuthnRequest 被压缩 + 编码后<br/>放在 URL 参数中(SAMLRequest)
U->>IdP: 3. 浏览器跳到 IdP 登录页
IdP->>U: 4. 展示登录表单
U->>IdP: 5. 输入账号密码
IdP->>IdP: 6. 验证凭证,生成 SAMLResponse
IdP->>U: 7. 返回一个自动提交的 HTML 表单
Note left of IdP: 表单里藏着 SAMLResponse<br/>(Base64 编码的 XML 断言)
U->>SP: 8. 浏览器自动提交表单到 SP
SP->>SP: 9. 验证签名 + 解析断言 + 提取用户信息
SP-->>U: 10. 登录成功
```
> [!tip] 为什么用 POST 而不是 GET?
> SAMLResponse 是一段大的 Base64 XML,超过 URL 长度限制。所以 IdP 返回一个自动提交的 HTML Form,用 POST 送到 SP 的 ACS(Assertion Consumer Service)端点。
> [!info] 为什么 IdP 返回的是 HTML 表单而不是直接跳转?
> SAMLResponse(断言 XML)体积很大,超过浏览器 URL 长度限制(通常 2KB)。所以 IdP 返回一个隐藏的 HTML 表单,用 JavaScript 自动 POST 到 SP。用户通常感知不到这个过程。
### AuthnRequest 示例
## SP 配置清单
```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>
```
接入 SAML 前,你需要和 IdP 管理员交换以下配置:
### SAMLResponse 示例(简化)
**你需要提供给 IdP 的:**
| 配置项 | 说明 | 示例 |
|--------|------|------|
| **Entity ID** | SP 的唯一标识,相当于 SP 的「身份证号」 | `https://app.example.com` |
| **ACS URL** | 接收 SAMLResponse 的端点(Assertion Consumer Service) | `https://app.example.com/saml/acs` |
| **NameID Format** | 你希望 IdP 用什么格式标识用户 | `emailAddress`(用邮箱标识) |
| **SP 证书** | 你的公钥,IdP 用它加密发给你的断言 | PEM 格式证书 |
**你需要从 IdP 获取的:**
| 配置项 | 说明 |
|--------|------|
| **SSO URL** | IdP 的登录端点,SP 把用户重定向到这里 |
| **IdP 证书** | IdP 的公钥,你用它验证 SAMLResponse 的签名 |
| **IdP Metadata URL** | IdP 的配置文件 URL,包含以上所有信息 |
> [!tip] Metadata 文件是什么?
> IdP 和 SP 各自暴露一个 **Metadata XML** 文件,描述自己的端点、证书、支持的功能。通过交换 Metadata URL 就能完成大部分配置,不需要手动复制粘贴各项参数。大多数 SAML 库支持自动拉取 Metadata。
## XML 结构拆解
SAML 的 XML 很长,但结构是有规律的。下面把 SAMLResponse 拆成一块块来讲:
### 第一层:Response 信封
```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 数字签名 -->
Destination="https://app.example.com/saml/acs" <!-- SP 的 ACS 地址 -->
InResponseTo="_abc123"> <!-- 对应哪个 AuthnRequest -->
<saml:Issuer>https://idp.example.com</saml:Issuer> <!-- IdP 标识 -->
<ds:Signature>...</ds:Signature> <!-- 整个 Response 的签名 -->
```
### 第二层:状态码
```xml
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
<!-- 状态码:Success 表示认证成功,还有各种错误码 -->
</samlp:Status>
```
### 第三层:Assertion(断言)—— 核心数据
```xml
<saml:Assertion ID="_def456" IssueInstant="2026-07-13T02:03:05Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature>...</ds:Signature>
<saml:Issuer>https://idp.example.com</saml:Issuer> <!-- 再次声明签发者 -->
<ds:Signature>...</ds:Signature> <!-- 断言自身的签名 -->
```
### 第四层:Subject(用户身份)
```xml
<saml:Subject>
<saml:NameID Format="...emailAddress">user@example.com</saml:NameID>
<saml:NameID Format="urn:...: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"/>
NotOnOrAfter="2026-07-13T02:08:05Z" <!-- 这份证明 5 分钟后过期 -->
Recipient="https://app.example.com/saml/acs" <!-- 只能发给这个地址 -->
InResponseTo="_abc123"/> <!-- 只响应这个请求 -->
</saml:SubjectConfirmation>
</saml:Subject>
```
### 第四层:Conditions(使用条件)
```xml
<saml:Conditions NotBefore="..." NotOnOrAfter="...">
<saml:AudienceRestriction>
<saml:Audience>https://app.example.com</saml:Audience>
<!-- 只有这个 SP 能用这份断言 -->
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="...">
```
### 第四层:AuthnStatement(认证方式)
```xml
<saml:AuthnStatement AuthnInstant="2026-07-13T02:03:00Z">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
<!-- 认证方式:密码认证(通过 HTTPS 传输) -->
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
```
### 第四层:AttributeStatement(用户属性)
```xml
<saml:AttributeStatement>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="email">
<saml:AttributeValue>user@example.com</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
```
## SP 配置要点
## Go 接入示例
接入 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 生态中 SAML 库较少,推荐使用 `crewjam/saml`:
```go
// 使用 crewjam/saml 库
import "github.com/crewjam/saml/samlsp"
// 创建 SAML 中间件
// 1. 从 IdP 的 Metadata URL 自动获取端点和证书
idpMetadataURL, _ := url.Parse("https://idp.example.com/metadata")
// 2. 创建 SAML 中间件
rootURL, _ := url.Parse("https://app.example.com")
samlSP, _ := samlsp.New(samlsp.Options{
EntityID: "https://app.example.com",
EntityID: "https://app.example.com", // SP 的唯一标识
URL: *rootURL,
Key: tlsCert.PrivateKey.(crypto.Signer),
Certificate: tlsCert.Leaf,
IDPMetadata: idpMetadataURL, // IdP 的 Metadata XML URL
Key: tlsCert.PrivateKey.(crypto.Signer), // SP 的私钥(用于解密)
Certificate: tlsCert.Leaf, // SP 的证书
IDPMetadata: idpMetadataURL, // IdP 的 Metadata URL
})
// 注册路由
http.Handle("/saml/", samlSP) // SAML 端点(ACS、Metadata)
http.Handle("/protected", samlSP.RequireAccount(handler)) // 受保护路由
// 3. 注册路由
http.Handle("/saml/", samlSP) // SAML 相关端点
http.Handle("/protected", samlSP.RequireAccount(handler)) // 需要登录的页面
```
> 中间件会自动处理:收到 SAMLResponse → 验证签名 → 解析 Assertion → 创建 Session。
## NameID:用户标识格式
NameID 是 IdP 用来标识用户的字段,有不同的格式:
| Format | 长什么样 | 适用场景 |
|--------|----------|----------|
| `emailAddress` | `user@example.com` | 最常用,可读,但邮箱可能变 |
| `persistent` | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | 随机 UUID,不暴露隐私,跨登录不变 |
| `transient` | `临时随机字符串` | 每次登录都不同,SP 无法跨次追踪用户 |
> [!warning] 不要用 NameID 做业务主键
> NameID 是**认证标识**,不是业务 ID。如果用户换了邮箱,`emailAddress` 格式的 NameID 就变了。你的业务系统应该用内部生成的稳定 ID 做关联,NameID 只用于登录时匹配用户。
## 常见陷阱与最佳实践
**XML Signature Wrapping 攻击**
- SAML 最大的安全风险。攻击者在 XML 中插入额外的 Assertion,利用解析器的逻辑漏洞
- 校验签名后,必须**重新提取** Subject 和 Conditions,不要信任解析位置
### XML Signature Wrapping 攻击
**时钟偏移**
- SAML Assertion 的有效期通常只有 5 分钟,IdP 和 SP 之间时钟不同步会导致验证失败
- 建议允许 30 秒 - 2 分钟的时钟偏移容差
这是 SAML 最大的安全风险。攻击原理:
**Logout 的复杂性**
- SAML SLO(Single Logout)需要所有 SP 协同,实际部署中经常不工作
- 很多企业选择**仅清除本地 Session**,不做真正的 SLO
1. 正常的 SAMLResponse 里有一个 Assertion,签名校验通过
2. 攻击者在 XML 中**插入另一个 Assertion**(用同一个签名),利用 XML 解析器的位置逻辑漏洞,让 SP 读到被篡改的 Assertion 但签名验证却通过
**Metadata 自动刷新**
- IdP 的签名证书会轮换,SP 应定期拉取 IdP Metadata XML 更新证书
- 不要硬编码 IdP 证书
**防御**:签名验证后,必须从签名**引用的位置**提取 Assertion,不要信任 XML 的默认解析顺序。
**NameID 的选择**
### 时钟偏移
| Format | 特点 | 适用场景 |
|--------|------|----------|
| `emailAddress` | 可读,但邮箱会变 | 外部用户 |
| `persistent` | 不可读的随机 ID | 需要隐私保护 |
| `transient` | 每次登录不同 | 一次性访问 |
SAML Assertion 的有效期通常只有 5 分钟。如果 IdP 和 SP 的服务器时钟差超过 5 分钟,验证就会失败。
> [!warning] 不要用 NameID 做业务关联
> NameID 是认证标识,不是业务 ID。如果用户邮箱变了,NameID 变了,你的业务系统不应该因此丢失用户数据。用一个稳定的内部 ID 做映射。
**建议**:允许 30 秒 ~ 2 分钟的时钟偏移容差。
### Logout 很难做好
SAML 的 SLO(Single Logout)需要所有 SP 协同——用户在 A 系统登出时,要通知 B、C、D 系统也登出。实际部署中,很多 SP 不实现 SLO 回调。
**务实的做法**:很多企业只清除本地 Session,不做真正的 SLO。或者用 OIDC 的 Session Management 替代。
### 证书轮换
IdP 的签名证书会定期更换。SP 应**定期拉取 IdP Metadata** 更新证书,不要硬编码。
> [!tip] 调试工具
> - **SAML Tracer**(Firefox 插件):捕获浏览器中的 SAML 请求和响应
> - **samltool.com**:在线解码和验证 SAML XML
> - 开启 SP 的 SAML 调试日志,打印收到的 SAMLResponse XML