This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
# SSO 单点登录:SAML 与 OAuth2.0
|
||||
|
||||
!!! note "一次登录,处处通行 — 理解企业级 SSO 的两大核心协议 SAML 2.0 与 OAuth2.0/OIDC 的设计原理、流程差异与选型策略"
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
1. **SSO(Single Sign-On)** — 用户在一个系统登录后,无需重复认证即可访问所有互信的应用系统
|
||||
2. **IdP(Identity Provider)** — 身份提供方,负责用户认证并签发身份令牌
|
||||
3. **SP / RP(Service / Relying Party)** — 服务提供方,依赖 IdP 的认证结果来授权用户
|
||||
4. **SAML 2.0** — 基于 XML 的企业级联合身份认证协议,擅长 Web SSO 与跨组织信任
|
||||
5. **OAuth2.0 + OIDC** — 基于 JSON/REST 的授权框架,叠加 OpenID Connect 实现轻量级身份认证
|
||||
|
||||
---
|
||||
|
||||
## 为什么需要 SSO
|
||||
|
||||
在微服务和多系统并存的架构下,每个系统独立维护账号体系会导致:
|
||||
|
||||
- **用户体验差**:用户需在 N 个系统分别登录
|
||||
- **安全风险高**:密码分散存储,泄露面大
|
||||
- **管理成本高**:员工离职时需逐系统注销
|
||||
|
||||
SSO 通过**中心化认证 + 分布式授权**解决这些问题:用户只与 IdP 交互一次,各 SP/RP 信任 IdP 签发的凭证。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
U[用户] -->|1. 访问| SP1[应用 A]
|
||||
SP1 -->|2. 重定向到 IdP| IDP[身份提供方]
|
||||
U -->|3. 登录认证| IDP
|
||||
IDP -->|4. 返回令牌| SP1
|
||||
U -->|5. 无需再登录| SP2[应用 B]
|
||||
U -->|5. 无需再登录| SP3[应用 C]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SAML 2.0
|
||||
|
||||
### 概述
|
||||
|
||||
SAML(Security Assertion Markup Language)2.0 是由 OASIS 制定的基于 XML 的开放标准,主要用于**企业级 Web 单点登录**和**跨组织联合身份管理**。它定义了 IdP 与 SP 之间交换安全断言(Assertion)的格式和协议。
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **Assertion** | SAML 的核心数据结构,包含认证声明、属性声明、授权决策声明 |
|
||||
| **Protocol** | 定义请求/响应的消息格式(AuthnRequest、Response) |
|
||||
| **Binding** | 定义 SAML 消息如何绑定到传输协议(HTTP Redirect、HTTP POST、Artifact) |
|
||||
| **Metadata** | SP 和 IdP 的元数据 XML,描述端点地址、支持的绑定、证书等 |
|
||||
|
||||
### Assertion 结构
|
||||
|
||||
```xml
|
||||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="_abc123" IssueInstant="2026-09-14T10:00:00Z" Version="2.0">
|
||||
<saml:Issuer>https://idp.example.com</saml:Issuer>
|
||||
<ds:Signature>...</ds:Signature>
|
||||
<saml:Subject>
|
||||
<saml:NameID>user@example.com</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="bearer">
|
||||
<saml:SubjectConfirmationData
|
||||
InResponseTo="_req456"
|
||||
NotOnOrAfter="2026-09-14T10:05:00Z"
|
||||
Recipient="https://sp.example.com/acs"/>
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="..." NotOnOrAfter="...">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>https://sp.example.com</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="2026-09-14T10:00:00Z">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>
|
||||
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
|
||||
</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
<saml:AttributeStatement>
|
||||
<saml:Attribute Name="role">
|
||||
<saml:AttributeValue>admin</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>
|
||||
```
|
||||
|
||||
### SP-Initiated SSO 流程(最常见)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户浏览器
|
||||
participant SP as Service Provider
|
||||
participant IDP as Identity Provider
|
||||
|
||||
U->>SP: 1. 访问受保护资源
|
||||
SP->>U: 2. 302 重定向到 IdP<br/>(携带 AuthnRequest)
|
||||
U->>IDP: 3. GET/POST SAMLRequest
|
||||
IDP->>U: 4. 展示登录页
|
||||
U->>IDP: 5. 提交凭证(用户名/密码)
|
||||
IDP->>IDP: 6. 验证凭证
|
||||
IDP->>U: 7. 返回自动提交的 HTML 表单<br/>(包含签名的 SAMLResponse)
|
||||
U->>SP: 8. POST SAMLResponse 到 ACS
|
||||
SP->>SP: 9. 验证签名、检查时间窗口、<br/>校验 Audience 和 Recipient
|
||||
SP->>U: 10. 签发本地 Session,302 重定向到目标资源
|
||||
```
|
||||
|
||||
### IdP-Initiated SSO
|
||||
|
||||
用户直接从 IdP 的门户(如企业门户页)点击应用图标进入 SP,此时没有 `InResponseTo`,SP 必须额外校验以防止**未经请求的 Assertion 注入攻击**。
|
||||
|
||||
### 安全要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| **XML Signature** | Assertion 必须由 IdP 私钥签名,SP 用 IdP 公钥验证 |
|
||||
| **XML Encryption** | 敏感属性(如邮箱、手机号)可加密传输 |
|
||||
| **Replay 防御** | SP 需缓存 Assertion ID,在 `NotOnOrAfter` 过期前拒绝重放 |
|
||||
| **Audience Restriction** | 确保 Assertion 只能被目标 SP 使用 |
|
||||
| **证书轮换** | IdP/SP 通过 Metadata 交换证书,支持双证书平滑过渡 |
|
||||
|
||||
---
|
||||
|
||||
## OAuth2.0 与 OpenID Connect
|
||||
|
||||
### OAuth2.0 概述
|
||||
|
||||
OAuth2.0 是一个**授权框架**(RFC 6749),核心目标是让第三方应用在**不接触用户密码**的前提下,获取对用户资源的受限访问。它本身**不是认证协议**。
|
||||
|
||||
### 四种授权模式
|
||||
|
||||
| 模式 | 适用场景 | 安全性 |
|
||||
|------|----------|--------|
|
||||
| **Authorization Code** | 有后端的 Web 应用(最推荐) | 高:code 一次性换取 token |
|
||||
| **Authorization Code + PKCE** | SPA / 移动端(替代 Implicit) | 高:无 client_secret 也能防拦截 |
|
||||
| **Client Credentials** | 服务间通信(M2M) | 高:无用户参与 |
|
||||
| ~~Implicit~~ | ~~SPA(已废弃)~~ | ~~低:token 直接暴露在 URL~~ |
|
||||
|
||||
### Authorization Code 流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户浏览器
|
||||
participant RP as Relying Party (客户端)
|
||||
participant AS as Authorization Server
|
||||
participant RS as Resource Server
|
||||
|
||||
U->>RP: 1. 点击"登录"
|
||||
RP->>U: 2. 302 重定向到 AS<br/>/authorize?response_type=code<br/>&client_id=xxx&redirect_uri=xxx<br/>&scope=openid profile&state=abc
|
||||
U->>AS: 3. 登录并授权
|
||||
AS->>U: 4. 302 重回 redirect_uri<br/>?code=xxx&state=abc
|
||||
U->>RP: 5. 携带 code 到 RP
|
||||
RP->>AS: 6. POST /token<br/>code + client_secret
|
||||
AS->>RP: 7. access_token + refresh_token<br/>(+ id_token 若 OIDC)
|
||||
RP->>RS: 8. Bearer access_token 访问资源
|
||||
RS->>RP: 9. 返回受保护数据
|
||||
```
|
||||
|
||||
### OpenID Connect(OIDC)
|
||||
|
||||
OIDC 是构建在 OAuth2.0 之上的**身份层**,在授权流程中增加了:
|
||||
|
||||
| 扩展 | 说明 |
|
||||
|------|------|
|
||||
| **ID Token** | JWT 格式,包含 `sub`(用户唯一标识)、`iss`、`aud`、`exp`、`nonce` 等 |
|
||||
| **UserInfo Endpoint** | `/userinfo` 端点,用 access_token 获取用户详细资料 |
|
||||
| **Discovery** | `/.well-known/openid-configuration`,自动发现端点和能力 |
|
||||
| ** scopes ** | `openid`(必须)、`profile`、`email`、`address`、`phone` |
|
||||
|
||||
```json
|
||||
// ID Token (JWT Payload)
|
||||
{
|
||||
"iss": "https://accounts.example.com",
|
||||
"sub": "user-uuid-12345",
|
||||
"aud": "client-id-abc",
|
||||
"exp": 1726304400,
|
||||
"iat": 1726300800,
|
||||
"nonce": "random-nonce",
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"picture": "https://cdn.example.com/avatar.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
### PKCE(Proof Key for Code Exchange)
|
||||
|
||||
针对公开客户端(SPA、移动端)无法安全存储 `client_secret` 的问题,PKCE 用动态生成的挑战码替代:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 公开客户端
|
||||
participant AS as Authorization Server
|
||||
|
||||
C->>C: 生成 code_verifier (43-128 字符随机串)
|
||||
C->>C: code_challenge = BASE64URL(SHA256(code_verifier))
|
||||
C->>AS: /authorize?...&code_challenge=xxx<br/>&code_challenge_method=S256
|
||||
AS->>C: code
|
||||
C->>AS: /token?code=xxx&code_verifier=yyy
|
||||
AS->>AS: 验证 SHA256(code_verifier) == code_challenge
|
||||
AS->>C: access_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SAML vs OAuth2.0/OIDC 对比
|
||||
|
||||
| 维度 | SAML 2.0 | OAuth2.0 + OIDC |
|
||||
|------|----------|-----------------|
|
||||
| **数据格式** | XML | JSON / JWT |
|
||||
| **传输方式** | HTTP POST / Redirect(XML payload) | REST API(JSON body) |
|
||||
| **协议定位** | 联合身份认证 | 授权框架 + 身份层(OIDC) |
|
||||
| **Token 类型** | XML Assertion | access_token / id_token (JWT) |
|
||||
| **典型场景** | 企业 Web SSO、跨组织联邦 | 互联网应用、移动端、SPA、API 授权 |
|
||||
| **移动端支持** | 差(XML 解析重、流程笨重) | 原生支持(REST + JSON) |
|
||||
| **Token 有效期** | 通常几分钟(短生命周期) | access_token 短(分钟级),refresh_token 长(天/月) |
|
||||
| **属性传递** | Assertion 中的 AttributeStatement | UserInfo Endpoint / ID Token Claims |
|
||||
| **生态工具** | Shibboleth、SimpleSAMLphp、ADFS | Keycloak、Auth0、Okta、自研网关 |
|
||||
| **学习曲线** | 高(XML Schema、签名/加密复杂) | 中(REST 友好,但安全细节多) |
|
||||
|
||||
### 如何选型
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[需要 SSO] --> B{目标用户群?}
|
||||
B -->|企业内部 / B2B| C{已有 SAML IdP?}
|
||||
B -->|互联网用户 / B2C| D[OAuth2.0 + OIDC]
|
||||
C -->|是| E[SAML 2.0]
|
||||
C -->|否 / 新建| F{是否需要移动端?}
|
||||
F -->|主要是 Web| E
|
||||
F -->|需要移动端 / API| D
|
||||
D --> G{需要用户身份?}
|
||||
G -->|是| H[OIDC]
|
||||
G -->|仅授权| I[纯 OAuth2.0]
|
||||
```
|
||||
|
||||
**实际项目中的混合模式**:很多企业同时使用两种协议 — 对内用 SAML 对接已有 AD/LDAP,对外用 OIDC 服务互联网用户。Keycloak 等现代 IdP 同时支持两种协议。
|
||||
|
||||
---
|
||||
|
||||
## Go 代码示例
|
||||
|
||||
### OAuth2.0 + OIDC 授权码流程(服务端)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
clientID = "my-app"
|
||||
clientSecret = "my-secret"
|
||||
redirectURL = "http://localhost:8080/callback"
|
||||
issuerURL = "https://accounts.example.com"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// 初始化 OIDC Provider(自动发现端点和公钥)
|
||||
provider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 配置 OAuth2.0
|
||||
conf := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
|
||||
// 用于验证 ID Token 的 verifier
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
|
||||
|
||||
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
// 生成 state 防 CSRF
|
||||
state := generateRandomString(32)
|
||||
// 生产环境应把 state 存入 session
|
||||
http.Redirect(w, r, conf.AuthCodeURL(state), http.StatusFound)
|
||||
})
|
||||
|
||||
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
// 校验 state(略)
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
// 用 code 换取 token
|
||||
token, err := conf.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
http.Error(w, "token exchange failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 ID Token
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "no id_token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "id_token verification failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析用户信息
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
http.Error(w, "claims parse failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 签发本地 session,返回用户信息
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(claims)
|
||||
})
|
||||
|
||||
log.Println("OAuth2 OIDC demo running on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
func generateRandomString(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
```
|
||||
|
||||
### SAML SP 侧验证(简化示例)
|
||||
|
||||
```go
|
||||
// 使用 crewjam/saml 库
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 解析 IdP 公钥(生产环境从 Metadata XML 自动获取)
|
||||
keyPair, _ := tls.LoadX509KeyPair("sp.crt", "sp.key")
|
||||
idpMetadataURL, _ := url.Parse("https://idp.example.com/metadata")
|
||||
|
||||
samlSP, _ := samlsp.New(samlsp.Options{
|
||||
EntityID: "https://sp.example.com",
|
||||
URL: *idpMetadataURL,
|
||||
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
|
||||
Certificate: keyPair.Leaf,
|
||||
IDPMetadataURL: idpMetadataURL,
|
||||
})
|
||||
|
||||
// 受保护路由
|
||||
http.Handle("/saml/", samlSP) // SAML 端点
|
||||
http.Handle("/protected", samlSP.RequireAccount( // 需要认证
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := samlsp.SessionFromContext(r.Context())
|
||||
// session.(samlsp.SessionWithAttributes) 可获取 SAML 属性
|
||||
w.Write([]byte("已认证用户"))
|
||||
}),
|
||||
))
|
||||
|
||||
http.ListenAndServe(":8081", nil)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
!!! warning "陷阱一:把 OAuth2.0 当认证协议"
|
||||
OAuth2.0 是授权框架,`access_token` 不代表用户身份。直接拿 `access_token` 判断"用户是谁"是不安全的 — 因为 token 可能被转发。必须使用 OIDC 的 `id_token` 并验证其签名、`iss`、`aud` 和 `nonce`。
|
||||
|
||||
!!! warning "陷阱二:SAML Assertion 重放攻击"
|
||||
SP 必须缓存已消费的 Assertion ID(至少在 `NotOnOrAfter` 时间窗口内),否则攻击者可以截获 Assertion 并重放。很多框架默认不开启此检查。
|
||||
|
||||
!!! warning "陷阱三:state / nonce 参数省略"
|
||||
`state`(OAuth)和 `nonce`(OIDC)用于防止 CSRF 和重放攻击。开发环境省略它们能跑通,但上线后等于敞开大门。`state` 必须与 session 绑定并一次性使用。
|
||||
|
||||
!!! warning "陷阱四:Implicit Flow 用于 SPA"
|
||||
Implicit Flow 将 token 直接暴露在 URL fragment 中,容易被浏览器历史、Referer 头或恶意 JS 截获。RFC 9262 已明确废弃 Implicit Flow,SPA 应使用 Authorization Code + PKCE。
|
||||
|
||||
---
|
||||
|
||||
## 练习题
|
||||
|
||||
??? question "题目一:SAML SP-Initiated 流程中,为什么 Assertion 要包含 AudienceRestriction?"
|
||||
??? success "答案"
|
||||
AudienceRestriction 限定了 Assertion 只能被指定的 SP 使用。如果没有这个约束,攻击者可以将合法 Assertion 转发给另一个 SP(Assertion substitution attack),冒充用户在目标 SP 登录。SP 在验证时必须检查 Audience 是否包含自己的 Entity ID。
|
||||
|
||||
??? question "题目二:为什么 OAuth2.0 的 Authorization Code 模式比 Implicit 模式更安全?"
|
||||
??? success "答案"
|
||||
Authorization Code 模式中,token 通过后端 HTTP POST 直接交换,不经过浏览器 URL,避免了 token 泄露到浏览器历史、Referer 头或日志中。同时可以使用 client_secret 验证客户端身份。而 Implicit 模式将 token 直接放在 URL fragment 中,对公开客户端无法验证身份,且 token 暴露面大。SPA 更推荐 Authorization Code + PKCE 方案。
|
||||
|
||||
??? question "题目三:在微服务架构中,用户通过 OIDC 登录后,如何在服务间传递用户身份?"
|
||||
??? success "答案"
|
||||
常见方案有:(1)JWT 透传 — 网关验证 id_token 后,将 JWT 在服务间通过请求头传递,各服务本地验签;(2)Token Exchange — 网关用用户 token 换取服务间 token(RFC 8693),避免用户 token 直接暴露给内部服务;(3)Session + Claims 传播 — 网关建立 session 后,将用户 claims 放入内部请求头(如 `X-User-ID`),服务间信任内网请求。方案一最简单但需严格内网隔离,方案二最安全但复杂度高。
|
||||
|
||||
---
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [SAML 2.0 Core Specification (OASIS)](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) — SAML 2.0 核心规范
|
||||
- [RFC 6749 - OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth2.0 授权框架
|
||||
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) — OIDC 核心规范
|
||||
- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) — Proof Key for Code Exchange
|
||||
- [RFC 8693 - OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) — 服务间 Token 交换
|
||||
- [go-oidc (CoreOS)](https://github.com/coreos/go-oidc) — Go 语言 OIDC 库
|
||||
- [crewjam/saml](https://github.com/crewjam/saml) — Go 语言 SAML 库
|
||||
- [Keycloak](https://www.keycloak.org/) — 开源 IdP,同时支持 SAML 2.0 和 OIDC
|
||||
@@ -125,6 +125,8 @@ nav:
|
||||
- 缓存雪崩: architecture/cache/cache-avalanche.md
|
||||
- 缓存穿透: architecture/cache/cache-penetration.md
|
||||
- 多级缓存与读写策略: architecture/cache/cache-multilevel-read-write.md
|
||||
- SSO 单点登录:
|
||||
- SAML 与 OAuth2.0: architecture/sso/sso-saml-oauth2.md
|
||||
- 算法:
|
||||
- algorithm/index.md
|
||||
- 布隆过滤器:
|
||||
|
||||
Reference in New Issue
Block a user