vault backup: 2026-07-13 11:11:37
This commit is contained in:
+75
-52
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user