SSO 单点登录:SAML 与 OAuth2.0¶
一次登录,处处通行 — 理解企业级 SSO 的两大核心协议 SAML 2.0 与 OAuth2.0/OIDC 的设计原理、流程差异与选型策略
核心概念¶
- SSO(Single Sign-On) — 用户在一个系统登录后,无需重复认证即可访问所有互信的应用系统
- IdP(Identity Provider) — 身份提供方,负责用户认证并签发身份令牌
- SP / RP(Service / Relying Party) — 服务提供方,依赖 IdP 的认证结果来授权用户
- SAML 2.0 — 基于 XML 的企业级联合身份认证协议,擅长 Web SSO 与跨组织信任
- OAuth2.0 + OIDC — 基于 JSON/REST 的授权框架,叠加 OpenID Connect 实现轻量级身份认证
为什么需要 SSO¶
在微服务和多系统并存的架构下,每个系统独立维护账号体系会导致:
- 用户体验差:用户需在 N 个系统分别登录
- 安全风险高:密码分散存储,泄露面大
- 管理成本高:员工离职时需逐系统注销
SSO 通过**中心化认证 + 分布式授权**解决这些问题:用户只与 IdP 交互一次,各 SP/RP 信任 IdP 签发的凭证。
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 结构¶
<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 流程(最常见)¶
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) | 高:无用户参与 |
Authorization Code 流程¶
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 |
// 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 用动态生成的挑战码替代:
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 友好,但安全细节多) |
如何选型¶
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 授权码流程(服务端)¶
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 侧验证(简化示例)¶
// 使用 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)
}
常见陷阱¶
陷阱一:把 OAuth2.0 当认证协议
OAuth2.0 是授权框架,access_token 不代表用户身份。直接拿 access_token 判断"用户是谁"是不安全的 — 因为 token 可能被转发。必须使用 OIDC 的 id_token 并验证其签名、iss、aud 和 nonce。
陷阱二:SAML Assertion 重放攻击
SP 必须缓存已消费的 Assertion ID(至少在 NotOnOrAfter 时间窗口内),否则攻击者可以截获 Assertion 并重放。很多框架默认不开启此检查。
陷阱三:state / nonce 参数省略
state(OAuth)和 nonce(OIDC)用于防止 CSRF 和重放攻击。开发环境省略它们能跑通,但上线后等于敞开大门。state 必须与 session 绑定并一次性使用。
陷阱四:Implicit Flow 用于 SPA
Implicit Flow 将 token 直接暴露在 URL fragment 中,容易被浏览器历史、Referer 头或恶意 JS 截获。RFC 9262 已明确废弃 Implicit Flow,SPA 应使用 Authorization Code + PKCE。
练习题¶
题目一:SAML SP-Initiated 流程中,为什么 Assertion 要包含 AudienceRestriction?
答案
AudienceRestriction 限定了 Assertion 只能被指定的 SP 使用。如果没有这个约束,攻击者可以将合法 Assertion 转发给另一个 SP(Assertion substitution attack),冒充用户在目标 SP 登录。SP 在验证时必须检查 Audience 是否包含自己的 Entity ID。
题目二:为什么 OAuth2.0 的 Authorization Code 模式比 Implicit 模式更安全?
答案
Authorization Code 模式中,token 通过后端 HTTP POST 直接交换,不经过浏览器 URL,避免了 token 泄露到浏览器历史、Referer 头或日志中。同时可以使用 client_secret 验证客户端身份。而 Implicit 模式将 token 直接放在 URL fragment 中,对公开客户端无法验证身份,且 token 暴露面大。SPA 更推荐 Authorization Code + PKCE 方案。
题目三:在微服务架构中,用户通过 OIDC 登录后,如何在服务间传递用户身份?
答案
常见方案有:(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) — SAML 2.0 核心规范
- RFC 6749 - OAuth 2.0 Authorization Framework — OAuth2.0 授权框架
- OpenID Connect Core 1.0 — OIDC 核心规范
- RFC 7636 - PKCE — Proof Key for Code Exchange
- RFC 8693 - OAuth 2.0 Token Exchange — 服务间 Token 交换
- go-oidc (CoreOS) — Go 语言 OIDC 库
- crewjam/saml — Go 语言 SAML 库
- Keycloak — 开源 IdP,同时支持 SAML 2.0 和 OIDC