49.2 单点登录 (SSO)

一句话

SSO 把"每个应用各自认证"换成"一个 IdP 签发断言,每个应用验签":登录只发生一次,信任靠密码学而不是靠网络位置传递;它解决的是登录体验与凭据集中,不解决授权——授权见 49.3 权限中心与权限矩阵。

五个误区

误区 现实
SSO 就是一个统一登录页 登录页只是壳。真正的三件事是:断言格式、验签规则、登出。前两件做错等于没做,第三件几乎做不完美
接了 OIDC 就安全了 redirect_uri 通配、state 不复用、令牌塞 localStorage,任意一处就能让整套 OIDC 归零
SSO 能统一登出 登出是 SSO 最不可靠的部分;SAML 的 SLO 在真实环境里经常只能登出当前应用
SSO 就是 OAuth2 OAuth2 是授权委托协议,本身不是认证协议。"用 OAuth2 做登录"必须落在 OIDC 上(多一个 id_token + userinfo)
一个 IdP 通吃 企业客户要求接他们自己的 IdP(多为 SAML),C 端要微信 / Apple / 手机号,多 IdP 是常态而非例外

四种形态,各有各的死角

方案 断言形态 签名 移动端 登出 实现成本 典型场景
SAML 2.0 XML Assertion XMLDSig(RSA-SHA256) 差(要 WebView) SLO 双通道,实际常失效 高(XML 规范化坑多) 政企存量系统、客户指定
OIDC id_token(JWT) + access_token JWS(RS256 / ES256) 好 back-channel logout(依赖 sid) 中 新系统首选
CAS Service Ticket 服务端回查 中 支持单点登出 低 高校、内网存量
反代 + 请求头注入(oauth2-proxy 类) Cookie + 注入头 反代本地校验后注入 差 清 Cookie 最低 内部工具、零改造老系统

🔑 新系统只选 OIDC。客户坚持 SAML 时,在 IdP 侧做协议转换(Keycloak 的 identity brokering 就是把上游 SAML 转成本地 OIDC),而不是在业务代码里同时写 SAML 与 OIDC 两套解析器——两套解析器意味着两倍的签名校验漏洞面。

授权码 + PKCE:六步,缺一不可

用户     浏览器 / RP                          IdP
 |  点登录  |                                  |
 |--------->| 302 /authorize?response_type=code
 |          |     &client_id=..&redirect_uri=..&scope=openid
 |          |     &state=<32B 随机>&nonce=<32B 随机>
 |          |     &code_challenge=S256(verifier)
 |          |--------------------------------->|
 |          |           登录 + MFA              |
 |          |<---------------------------------| 302 redirect_uri?code=..&state=..
 |          | ① 校验 state 与发出时一致          |
 |          | POST /token  (code + code_verifier + 客户端认证)
 |          |--------------------------------->|
 |          |<---------------------------------| id_token + access_token (+ refresh)
 |          | ② 验签 ③ iss ④ aud ⑤ nonce ⑥ exp  |
 |  登录成功 |                                  |

每个参数都对应一类攻击,少一个就多一条洞:

参数 / 校验 挡什么攻击 忘了的后果
state(一次性、32 字节随机) CSRF:把受害者浏览器绑到攻击者的授权码上 攻击者用自己的账号登录,把 code 塞给受害者 → 受害者后续操作全在攻击者账号下
nonce(写进 id_token) id_token 重放 攻击者拿旧的 id_token 反复换会话
PKCE(code_verifier → code_challenge) 授权码被中间人截获后兑换 公共客户端(SPA / App)无 client_secret,截获 code 即可换 token
redirect_uri 精确匹配 授权码被送到攻击者域名 通配 https://*.example.com 等于把 code 交给任何子域接管者
iss + aud(多 aud 还要查 azp) 令牌串用:A 站的 token 打 B 站 一个低权限应用的 id_token 直接登录高权限应用
exp + iat(允许 60s 时钟偏移) 过期令牌 / 时间伪造 或者反过来:时钟差 30 秒就全员登录失败
sid(会话 ID 声明) 登出无法定位会话 back-channel logout 收不到、也踢不掉

授权码有效期按 RFC 6749 的上限是 10 分钟,工程上取 60 秒且一次性使用:正常流程 1 秒内就能兑换完,留 10 分钟只是给攻击者留窗口。

验签要跑起来才算数

下面这段只用 Go 标准库实现 ES256 签发与验签,并把四种真实攻击打进去。注意签名格式:JWS 的 ES256 签名是定长的 R‖S(各 32 字节),不是 ecdsa.Sign 默认输出的 ASN.1 DER——这是自己实现 JWT 时最常踩的坑,格式错了对方一律验不过。

package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"math/big"
	"strings"
	"time"
)

var b64 = base64.RawURLEncoding

type header struct {
	Alg string `json:"alg"`
	Typ string `json:"typ"`
	Kid string `json:"kid"`
}

func sign(key *ecdsa.PrivateKey, kid string, claims map[string]any) string {
	h, _ := json.Marshal(header{Alg: "ES256", Typ: "JWT", Kid: kid})
	p, _ := json.Marshal(claims)
	signing := b64.EncodeToString(h) + "." + b64.EncodeToString(p)
	sum := sha256.Sum256([]byte(signing))
	r, s, _ := ecdsa.Sign(rand.Reader, key, sum[:])
	sig := make([]byte, 64) // JWS 要求定长 R||S,不是 ASN.1 DER
	r.FillBytes(sig[:32])
	s.FillBytes(sig[32:])
	return signing + "." + b64.EncodeToString(sig)
}

// verify 只接受 ES256:alg=none / HS256 一律拒绝,这是最常被绕过的一步
func verify(pub *ecdsa.PublicKey, jwt, wantAud string, now time.Time) (map[string]any, error) {
	parts := strings.Split(jwt, ".")
	if len(parts) != 3 {
		return nil, fmt.Errorf("段数不对")
	}
	rawH, err := b64.DecodeString(parts[0])
	if err != nil {
		return nil, err
	}
	var h header
	json.Unmarshal(rawH, &h)
	if h.Alg != "ES256" {
		return nil, fmt.Errorf("alg 不在白名单: %q", h.Alg)
	}
	sig, err := b64.DecodeString(parts[2])
	if err != nil || len(sig) != 64 {
		return nil, fmt.Errorf("签名长度不对")
	}
	sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
	r := new(big.Int).SetBytes(sig[:32])
	s := new(big.Int).SetBytes(sig[32:])
	if !ecdsa.Verify(pub, sum[:], r, s) {
		return nil, fmt.Errorf("验签失败")
	}
	rawP, _ := b64.DecodeString(parts[1])
	var claims map[string]any
	json.Unmarshal(rawP, &claims)

	if now.Unix() > int64(claims["exp"].(float64))+60 { // 60s leeway
		return nil, fmt.Errorf("已过期")
	}
	if aud, _ := claims["aud"].(string); aud != wantAud {
		return nil, fmt.Errorf("aud 不匹配:%q", aud)
	}
	return claims, nil
}

func main() {
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	now := time.Unix(1767225600, 0) // 固定时间,输出可复现
	claims := map[string]any{
		"iss": "https://idp.example.com", "sub": "01H8A", "aud": "billing-api",
		"exp": now.Unix() + 300, "iat": now.Unix(),
		"nonce": "n-7f3c", "sid": "s-2b91", "tenant": "t-001",
	}
	jwt := sign(key, "k1", claims)
	fmt.Printf("JWT = %d 字节;10k QPS 下 ≈ %.1f MB/s\n\n", len(jwt), float64(len(jwt))*10000/1e6)

	cases := []struct {
		name, tok, aud string
		at             time.Time
		ok             bool
	}{
		{"正常令牌", jwt, "billing-api", now, true},
		{"过期 5 分钟", jwt, "billing-api", now.Add(10 * time.Minute), false},
		{"过期 30 秒(60s leeway 内)", jwt, "billing-api", now.Add(5*time.Minute + 30*time.Second), true},
		{"aud 串用", jwt, "order-api", now, false},
		{"alg=none 提权", b64.EncodeToString([]byte(`{"alg":"none"}`)) + "." +
			b64.EncodeToString([]byte(`{"aud":"billing-api","exp":9999999999}`)) + ".", "billing-api", now, false},
		{"HS256 混淆(公钥当 HMAC 密钥)", hs256(key, claims), "billing-api", now, false},
	}
	fmt.Println("| 场景 | 期望 | 实际 |")
	fmt.Println("|---|---|---|")
	for _, c := range cases {
		_, err := verify(&key.PublicKey, c.tok, c.aud, c.at)
		got := err == nil
		verdict := "通过"
		if !got {
			verdict = "拒绝:" + err.Error()
		}
		mark := "✅"
		if got != c.ok {
			mark = "❌"
		}
		fmt.Printf("| %s %s | %v | %s |\n", mark, c.name, c.ok, verdict)
	}
}

// hs256 模拟 alg 混淆:攻击者把公开的验签公钥当 HMAC 密钥,把 alg 改成 HS256
func hs256(key *ecdsa.PrivateKey, claims map[string]any) string {
	pub, _ := json.Marshal(key.PublicKey)
	h, _ := json.Marshal(header{Alg: "HS256", Typ: "JWT"})
	p, _ := json.Marshal(claims)
	signing := b64.EncodeToString(h) + "." + b64.EncodeToString(p)
	m := hmac.New(sha256.New, pub)
	m.Write([]byte(signing))
	return signing + "." + b64.EncodeToString(m.Sum(nil))
}

输出(本机实测):

JWT = 339 字节;10k QPS 下 ≈ 3.4 MB/s

| 场景 | 期望 | 实际 |
|---|---|---|
| ✅ 正常令牌 | true | 通过 |
| ✅ 过期 5 分钟 | false | 拒绝:已过期 |
| ✅ 过期 30 秒(60s leeway 内) | true | 通过 |
| ✅ aud 串用 | false | 拒绝:aud 不匹配:"billing-api" |
| ✅ alg=none 提权 | false | 拒绝:alg 不在白名单: "none" |
| ✅ HS256 混淆(公钥当 HMAC 密钥) | false | 拒绝:alg 不在白名单: "HS256" |

alg 白名单必须写成枚举而不是"支持列表":if h.Alg != "ES256" { reject }。写成 switch h.Alg { case "ES256": ...; case "none": return claims } 是历史上真实发生过的提权漏洞类型。

令牌放哪:三种位置,三种泄露面

位置 XSS 能否偷 CSRF 跨域 / 第三方 移动端 结论
localStorage 能(任何脚本可读) 不受影响(不自动携带) 好 好 只适合无敏感操作的场景
HttpOnly + Secure + SameSite=Lax Cookie 不能 需 CSRF token 或 SameSite 需 CORS 配置 差 浏览器侧的默认选择
内存(JS 变量) 能(同页脚本) 不受影响 好 好 刷新即丢,要配静默续期
BFF(后端持有令牌,前端只拿会话 Cookie) 不能 同 Cookie 好(同源) 好 安全与复杂度之间最稳的一档

⚠️ HttpOnly 挡的是"偷令牌",挡不了"借你的手发请求"。用 Cookie 就一定要配 SameSite 或 CSRF token,两类攻击的边界见 38.6 常见攻击 (注入 / XSS / CSRF / SSRF / 越权 / 重放)。

单点登出:三种做法,只有一种能兜住

做法 生效延迟 可靠性 代价
前端清 Cookie,各应用各自为政 各应用自己决定 低:其他应用的会话还在 几乎为零,也几乎无效
协议级 SLO(SAML SLO / OIDC back-channel logout) 秒级 中:每个 RP 必须实现可公网访问、幂等的登出端点,且回调会重试 每个接入方都要改代码
会话中心 + 短 TTL(推荐兜底) ≤ access token TTL 高 每请求一次校验或一次本地缓存命中

现实中三者叠着用:协议级 SLO 尽力通知,会话中心保证"最多再活一个 TTL",前端的清理只影响体验不影响安全。

🔑 不要指望登出,要指望 TTL。 登出的语义是"通知所有可能持有我凭证的地方",这在分布式系统里不可靠;TTL 的语义是"凭证自己会死",这个是确定性的。撤销的具体手段见 49.5 登录态与令牌生命周期。

多 IdP:账号绑定是唯一的风险点

绑定策略 做法 风险
同邮箱自动合并 第三方返回的 email 与已有账号相同就并入 账号接管:部分 IdP 的 email 未经验证或可自填,攻击者填受害者邮箱即可登入
首次登录强制绑定 需要先用已有方式登录一次,确认后绑定 体验略差,但这是唯一安全的默认
完全独立账号 第三方身份单独开号 用户体验差(两个号),数据割裂

🔑 自动合并的前提是三个"必须":email_verified == true、IdP 在可信列表内、且该邮箱此前未被绑定过。缺一个就把"登录"变成了"接管"。

企业客户接自建 IdP 时,多一层"租户级 IdP 配置":租户管理员上传 IdP 元数据(SAML Metadata / OIDC discovery URL),系统按邮箱域名或租户 ID 路由到对应 IdP——这部分的路由与租户隔离见 49.4 多租户权限模型。

落地:指名 / 可抄 / 可验 / 可退

指名:自建 IdP 用 Keycloak(OIDC + SAML + identity brokering,一套顶住企业客户);K8s 环境里轻量方案是 Dex(kubernetes 生态默认,能力弱于 Keycloak);直接买 SaaS 则是 Auth0 / Okta / 飞书 / 企业微信。反向代理兜老系统用 oauth2-proxy。

可抄(OIDC discovery,标准端点,任何合规 IdP 都有;未验证:本机无 IdP 实例):

# 1) 发现端点:把 issuer 换成自己的 realm
curl -s https://idp.example.com/realms/prod/.well-known/openid-configuration | jq '{issuer,authorization_endpoint,token_endpoint,jwks_uri}'
# 2) 授权码换令牌(code 一次性,60 秒内用掉)
curl -s -X POST https://idp.example.com/realms/prod/protocol/openid-connect/token \
  -d grant_type=authorization_code -d code="$CODE" \
  -d client_id=billing -d redirect_uri=https://billing.example.com/callback \
  -d code_verifier="$VERIFIER" | jq '{id_token,access_token,expires_in}'

可验(本机可跑,零依赖——拿到任何 id_token 都能先看清它声称了什么):

TOKEN='eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6IjAxSDhBIiwiYXVkIjoiYmlsbGluZy1hcGkiLCJleHAiOjE3NjcyMjU5MDAsImlhdCI6MTc2NzIyNTYwMCwibm9uY2UiOiJuLTdmM2MiLCJzaWQiOiJzLTJiOTEiLCJ0ZW5hbnQiOiJ0LTAwMSJ9.c2ln'
python3 -c 'import base64,json,sys
for p in sys.argv[1].split(".")[:2]:
    print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))),ensure_ascii=False))' "$TOKEN"
{"alg": "ES256", "typ": "JWT", "kid": "k1"}
{"iss": "https://idp.example.com", "sub": "01H8A", "aud": "billing-api", "exp": 1767225900, "iat": 1767225600, "nonce": "n-7f3c", "sid": "s-2b91", "tenant": "t-001"}

解码不等于验证:上面这条命令不校验签名。它的用途是故障定位(令牌里到底有没有 tenant、aud 对不对),不是放行依据。

可退:SSO 让 IdP 成为全站单点。降级方案要提前写进 runbook:① IdP 不可用时,存量会话继续用到 TTL 到期,只停新登录(而不是全员掉线);② 每个核心应用保留一个 break-glass 本地管理员,独立于 IdP、使用即告警;③ 多 IdP 场景下,把一个 IdP 的故障域限制在它自己的租户内,不要让 A 客户的 IdP 抖动影响 B 客户。

反模式

反模式 后果
redirect_uri 用通配或前缀匹配 任意子域接管即可窃取授权码,SSO 变成统一入口的统一后门
把 access_token 当身份用(读它的 sub 做权限) access_token 是给资源服务器的不透明凭证,身份声明在 id_token;且它可能不是 JWT
自己实现 JWT 验签且允许 alg 由令牌自选 alg=none / HS256 混淆提权,历史上被反复利用
令牌写进 localStorage 又允许用户内容渲染 一个 XSS 就是全量账号接管,且攻击者能长期续期
SSO 一次接入就以为登出也通了 用户点了"退出",另一个应用里会话还在,共享电脑场景直接泄密
用邮箱自动合并第三方账号 第三方邮箱未验证时,账号接管只需要改一个邮箱字段
每个应用自己实现一套 OIDC 客户端 N 份验签代码 = N 份漏洞面,修一次要改 N 个仓库
state 生成后存全局变量 并发登录互相覆盖,CSRF 防护时灵时不灵

自检

Q1 用户在 A 应用点了"退出登录",B 应用里还能继续操作。这是 bug 还是设计?怎么把风险压到可接受?

▸ 答案:在大多数实现里这是设计缺陷的暴露,不是单点 bug:SLO 依赖每个 RP 都实现并正确处理登出回调,而回调可能丢、可能超时、可能某个 RP 压根没实现。可接受的做法是三层叠加——协议级 SLO 尽力通知;会话中心里删掉 sid 让后续请求失败;再把 access token TTL 压到 5–15 分钟,保证最坏情况下"退出后最多再活一个 TTL"。对外承诺要按这个口径写,不能承诺"立即全部下线"。

Q2 为什么 id_token 里已经有 sub,还必须在服务端校验 aud 与 iss?

▸ 答案:sub 只在"这个 iss 的命名空间内"唯一。不校验 iss,攻击者自建一个 IdP 签发 sub=admin 的令牌就能通过;不校验 aud,为低权限应用(如内部 Wiki)签发的合法令牌可以直接拿去访问高权限应用——这是"令牌串用",属于越权而非伪造,签名完全合法。多个 aud 时还要校验 azp 确认是发给自己的。

Q3 PKCE 对 confidential client(有 client_secret 的后端应用)还有必要吗?

▸ 答案:有。client_secret 保护的是"兑换授权码这一步的调用方身份",而 PKCE 保护的是"授权码本身不被别人兑换"。移动端 / SPA 拿不到 secret,PKCE 是唯一手段;后端应用虽然能守住 secret,但授权码仍会经过浏览器地址栏、Referer、日志,一旦泄漏,攻击者带着偷来的 code 直接打 /token 也能换到令牌——PKCE 让这一步必须出示只有发起方知道的 verifier。OAuth 2.1 草案已把 PKCE 列为所有客户端的必选。

Q4 一个 JWT 长 339 字节,10k QPS 意味着什么?这影响哪些设计?

▸ 答案:约 3.4 MB/s 的纯头部带宽(还没算 TLS 与 HTTP 头开销)。影响三件事:① 别把权限列表塞进 token,token 膨胀到几 KB 时,移动端弱网的首包时间会明显变差;② 网关日志/链路追踪里默认不记录完整 token(既占存储又是凭据泄漏源);③ 上游连接池与 header 缓冲要按 token 实际大小配置(某些默认 8KB 的 header 上限在塞了大 token + 长 cookie 后会 400)。

Q5 为什么说"解码令牌"不能当成"验证令牌"?给一个具体后果。

▸ 答案:解码只是 base64 反序列化,任何人都能构造一个 {"sub":"admin","aud":"billing-api","exp":9999999999} 的 payload 拼在任意 header 后面,解码出来"看起来完全正常"。把它当验证依据的后果是:攻击者不需要任何密钥就能伪造管理员身份。验证必须包含签名校验(用 jwks_uri 取公钥,按 kid 选键)与 iss/aud/exp 的语义校验,两步都做才算验过。

关联