From 7e159a087ad1ef577afc70a72469da6b72143dbe Mon Sep 17 00:00:00 2001 From: hezhaohui Date: Mon, 13 Jul 2026 11:11:37 +0800 Subject: [PATCH] vault backup: 2026-07-13 11:11:37 --- technical/sso.md | 127 ++++++++------ technical/sso/sso-cas.md | 233 +++++++++++++++----------- technical/sso/sso-comparison.md | 193 +++++++++++---------- technical/sso/sso-oauth2.md | 257 ++++++++++++++++++---------- technical/sso/sso-oidc.md | 224 ++++++++++++++++--------- technical/sso/sso-saml.md | 288 +++++++++++++++++++------------- 6 files changed, 807 insertions(+), 515 deletions(-) diff --git a/technical/sso.md b/technical/sso.md index d029ce3..2fb9ebb 100644 --- a/technical/sso.md +++ b/technical/sso.md @@ -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. 「你还没登录,去认证中心吧」
浏览器地址栏变成 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 协议]] — 按需阅读,了解企业级和传统方案 ## 关联笔记 diff --git a/technical/sso/sso-cas.md b/technical/sso/sso-cas.md index 5b4b0f7..95f9fd8 100644 --- a/technical/sso/sso-cas.md +++ b/technical/sso/sso-cas.md @@ -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. 「你还没登录」
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 形式
存在 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 - zhangsan - - -``` - -**CAS 3.0(支持属性释放):** -```xml - - - zhangsan + zhangsan - zhangsan@example.com - 张三 - engineering + zhangsan@example.com + 张三 + engineering ``` +> 接入时优先使用 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 值得考虑。 diff --git a/technical/sso/sso-comparison.md b/technical/sso/sso-comparison.md index 7ac0ea3..9ca8b6e 100644 --- a/technical/sso/sso-comparison.md +++ b/technical/sso/sso-comparison.md @@ -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[统一身份网关
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) ## 关联笔记 diff --git a/technical/sso/sso-oauth2.md b/technical/sso/sso-oauth2.md index 3607f0f..d449844 100644 --- a/technical/sso/sso-oauth2.md +++ b/technical/sso/sso-oauth2.md @@ -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、
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,
不经过浏览器 + 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 -->|有效期短
5-30 分钟| E[调用 API] + D -->|有效期长
几天到几个月| 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"` 是最差的做法。 diff --git a/technical/sso/sso-oidc.md b/technical/sso/sso-oidc.md index 3a5f22f..afedd1b 100644 --- a/technical/sso/sso-oidc.md +++ b/technical/sso/sso-oidc.md @@ -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 是新增的!
它包含用户的身份信息 + 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 登录。 diff --git a/technical/sso/sso-saml.md b/technical/sso/sso-saml.md index 890acdc..369a84b 100644 --- a/technical/sso/sso-saml.md +++ b/technical/sso/sso-saml.md @@ -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. 「你还没登录,去认证中心吧」
生成 AuthnRequest,浏览器 302 跳转 + Note right of SP: AuthnRequest 被压缩 + 编码后
放在 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
(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 - - https://app.example.com - - -``` +接入 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 - https://idp.example.com - ... + Destination="https://app.example.com/saml/acs" + InResponseTo="_abc123"> + https://idp.example.com + ... +``` + +### 第二层:状态码 + +```xml + +``` + +### 第三层:Assertion(断言)—— 核心数据 + +```xml - https://idp.example.com - ... + https://idp.example.com + ... +``` + +### 第四层:Subject(用户身份) + +```xml - user@example.com + + user@example.com + + NotOnOrAfter="2026-07-13T02:08:05Z" + Recipient="https://app.example.com/saml/acs" + InResponseTo="_abc123"/> +``` + +### 第四层:Conditions(使用条件) + +```xml https://app.example.com + - +``` + +### 第四层:AuthnStatement(认证方式) + +```xml + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + +``` + +### 第四层:AttributeStatement(用户属性) + +```xml admin + + user@example.com + ``` -## 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