跳转至

SSO 单点登录:SAML 与 OAuth2.0

一次登录,处处通行 — 理解企业级 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 签发的凭证。

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) 高:无用户参与
Implicit SPA(已废弃) 低:token 直接暴露在 URL

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),服务间信任内网请求。方案一最简单但需严格内网隔离,方案二最安全但复杂度高。


相关链接