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 的语义校验,两步都做才算验过。