5.3 OAuth2/OIDC 与第三方登录
5.1 和 5.2 把 TaskHub 自己的账号体系建完了:签发令牌、校验、角色、隔离。但企业客户上线第一周就会问:「我们全公司都用 Okta / 飞书 / 企业微信,能不能不要另建一套密码?」这个问题不能靠「把对方的用户名密码存进我们库」解决——那既不安全也不合规。正确答案是 OAuth2 授权码流程加 OIDC。
本节把 TaskHub 推进到:接上外部身份提供方(IdP),走授权码 + PKCE 拿到 ID token,验签后映射成本地账号,并给出账号绑定的安全规则。
5.3.1 为什么不是「代填用户名密码」
三种「第三方登录」做法,只有一种是对的:
| 做法 | 安全性 | 说明 |
|---|---|---|
| 让用户把第三方密码填进我们表单 | 极差 | 等于收集别人的密码,违反 OAuth 的核心原则 |
| 前端拿第三方 SDK 换到 token 再传给后端 | 差 | 后端无法验证 token 真伪与受众 |
| 后端走授权码流程,自己换 token | 好 | token 从 IdP 直连后端,前端碰不到 |
核心区别在于凭证在谁手里:授权码流程里,client_secret(或 PKCE 的 verifier)始终在后端,前端只经手一个一次性的 code。即使前端被完全控制,攻击者也换不到 token。
OAuth2 管「授权」——拿到一个能访问用户资源的 access token;OIDC 是在它之上加了一层身份层:多返回一个 id_token(就是一个 JWT),里面写明「这个人是谁」。TaskHub 关心的是身份,所以用的是 OIDC。
5.3.2 授权码 + PKCE 的完整流程
整个流程只有五步,但每步都有安全约束:
| 步骤 | 谁做 | 关键约束 |
|---|---|---|
| 1. 发现 IdP 配置 | 后端 | 从 /.well-known/openid-configuration 拿端点 |
2. 生成 state/nonce/PKCE | 后端 | state 防 CSRF,nonce 防重放,PKCE 防拦截 |
| 3. 用户跳转授权页 | 浏览器 | 带上 code_challenge(不是 verifier) |
4. IdP 回调带 code | 浏览器 | 后端先比对 state |
5. code + code_verifier 换 token | 后端 | 校验 id_token 签名与 nonce |
PKCE(Proof Key for Code Exchange,读作 “pixy”)是这里的核心。它的原理是:授权请求时只发 code_challenge = S256(code_verifier),换 token 时才发 code_verifier 本身。这样即使攻击者截获了授权码,没有 verifier 也换不到 token。对纯后端应用它同样有价值——它把「授权码泄露」从致命降级成无害。
state 和 nonce 分工不同,都不能省:
state:后端生成、存进会话,回调时比对。防止攻击者构造一个自己的code让受害者登录成攻击者的账号(CSRF / 登录固定)。nonce:放进授权请求,IdP 会把它原样写进id_token。后端比对,防止攻击者拿一个别处签发的合法id_token重放。
5.3.3 实测:起一个真实 IdP 跑通全流程
为了把链路真正验证一遍,我用 httptest 在本机起了一个符合 OIDC 的身份提供方:RSA 私钥签 ID token、暴露 JWKS 公钥、校验 PKCE。整条链路真实跑过,不是示意。
第一步,discovery 拿配置:
discovery: issuer=http://127.0.0.1:58116 alg=[RS256] pkce=[S256]
生产环境这里要校验 issuer 与配置的地址完全一致,否则攻击者可以自建一个 discovery 文档把端点指向自己的服务器。取配置的代码很短,但要注意两件事——超时与缓存:
func discover(ctx context.Context, issuer string) (oauth2.Endpoint, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // 别让 discovery 拖住登录
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", issuer+"/.well-known/openid-configuration", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return oauth2.Endpoint{}, err
}
defer resp.Body.Close()
var doc struct {
Issuer string `json:"issuer"`
Authorization string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
JWKS string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return oauth2.Endpoint{}, err
}
if doc.Issuer != issuer { // 防止 discovery 被指向别处
return oauth2.Endpoint{}, fmt.Errorf("issuer mismatch: got %q want %q", doc.Issuer, issuer)
}
return oauth2.Endpoint{AuthURL: doc.Authorization, TokenURL: doc.Token}, nil
}
两个工程细节:discovery 结果要缓存(IdP 的端点不会每分钟变,每次登录都拉一次是浪费,且 IdP 挂了就没人能登录);要设超时(5 秒够了,否则 IdP 网络抖动会拖住整个登录流程)。缓存策略用「进程内缓存 + 定期刷新」即可,第 7 章的本地缓存模式正好适用。
关于 scope,TaskHub 只申请三个,且遵循最小权限:
| scope | 用途 | 是否必需 |
|---|---|---|
openid | 请求 OIDC 身份层(拿 id_token) | 是,没有它就退化成纯 OAuth2 |
email | 拿 email 与 email_verified | 用于账号绑定提示 |
profile | 拿 name、picture | 用于展示,可省 |
不要顺手申请 offline_access(那会拿到 refresh token)或任何写权限的 scope。第三方登录只需要「读身份」,多要的权限一旦被审计出来就是合规问题。
第二步,生成 PKCE 与 state/nonce,构造授权 URL:
授权 URL 查询串: /authorize?client_id=taskhub-web&code_challenge=uk_K4sOpRL...&code_challenge_method=S256&nonce=u-wzgT...&redirect_uri=https%3A%2F%2Fapp.taskhub.example.com%2Fauth%2Fcallback&response_type=code&scope=openid+email+profile&state=7Jv4sEfNfU2tU7mD9-YOUw
注意 URL 里出现的是 code_challenge 而不是 code_verifier,且 code_challenge_method=S256。verifier 是 32 字节随机数的 base64url:
verifier := base64.RawURLEncoding.EncodeToString(randomBytes(32))
state := base64.RawURLEncoding.EncodeToString(randomBytes(16))
nonce := base64.RawURLEncoding.EncodeToString(randomBytes(16))
authURL := cfg.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier),
oauth2.SetAuthURLParam("nonce", nonce))
golang.org/x/oauth2 直接提供 S256ChallengeOption(授权用)与 VerifierOption(换 token 用),不用自己算 SHA-256。
第三步,浏览器被重定向回来,回调里带 code 和 state:
回调: code=code_N0p2NHNF state=7Jv4sEfNfU2tU7mD9-YOUw (state 匹配=true)
后端必须先比对 state,一致才继续。不一致直接终止——这一步防的是「攻击者把受害者浏览器引导到自己账号的授权码上」。
第四步,用 code + code_verifier 换 token:
换到 token: type=Bearer expires_in=5m0s refresh=true id_token 长度=645
5.3.4 一个真实的踩坑:Content-Type
这一步我第一次跑是失败的,值得记下来。客户端报:
换 token 失败: oauth2: server response missing access_token
服务端明明返回了 {"access_token":...}。原因在 x/oauth2 的实现:它靠响应的 Content-Type 判断该按 JSON 还是按 application/x-www-form-urlencoded 解析。我的测试 IdP 没设 Content-Type,于是客户端按表单格式去解析 JSON 字符串,自然找不到 access_token。
修复是在 IdP 的 token 端点补一行:
w.Header().Set("Content-Type", "application/json")
这个坑的价值在于:它会在你对接一个「文档说返回 JSON 但没设 Content-Type」的自建 IdP 时真实发生。排查方向不是「token 有没有返回」,而是「响应头对不对」。
5.3.5 验证 ID token
id_token 是一个 JWT,但不能用 client_secret 验签——它由 IdP 的私钥签,得用 IdP 的公钥。公钥从 JWKS 端点取:
func fetchRSAPublicKey(ctx context.Context, jwksURL string) (*rsa.PublicKey, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var doc struct {
Keys []struct {
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, err
}
k := doc.Keys[0]
nb, _ := base64.RawURLEncoding.DecodeString(k.N)
eb, _ := base64.RawURLEncoding.DecodeString(k.E)
return &rsa.PublicKey{
N: new(big.Int).SetBytes(nb),
E: int(new(big.Int).SetBytes(eb).Int64()),
}, nil
}
JWK 里的 RSA 公钥是 n(模数)和 e(指数)两个 base64url 大整数,拼成 rsa.PublicKey 即可。生产环境必须按 kid 选密钥并缓存——上面的 doc.Keys[0] 是简化写法,IdP 轮换密钥时会有多个 key,取错就验不过。缓存策略是「按 kid 缓存,遇到未知 kid 才重新拉一次 JWKS」。
拿到公钥后验签,同时锁算法、校 iss、校 aud:
var claims jwt.MapClaims
parsed, err := jwt.ParseWithClaims(idToken, &claims, func(t *jwt.Token) (any, error) {
return pub, nil
},
jwt.WithValidMethods([]string{"RS256"}), // 非对称必须锁算法
jwt.WithIssuer(issuer),
jwt.WithAudience("taskhub-web"),
)
实测结果:
ID token 校验: err=<nil> valid=true sub=idp|usr_1 email=alice@example.com nonce 匹配=true
注意 aud 这里是 taskhub-web(客户端 ID),不是 taskhub-api。OIDC 规定 ID token 的 aud 是发起授权的 client_id——这一点和 5.1 节自己签的 access token 不同,别抄错。
5.3.6 实测:两种攻击的拦截
PKCE 不匹配。用一个 verifier 生成 challenge,换 token 时故意传另一个 verifier:
用错的 verifier 换 token: err=oauth2: cannot fetch token: 400 Bad Request
Response: {"error":"invalid_grant","error_description":"pkce mismatch"}
IdP 回 400 invalid_grant。这就是 PKCE 的作用:攻击者截获了 code,但没有正确的 verifier,换不到 token。
state 不一致。如果回调里的 state 与本机会话里存的不一致,必须直接终止:
回调 state 与本地不一致时应当拒绝: true
实现上就是一句 if cb.Query().Get("state") != sessionState { return 拒绝 }。state 必须存在服务端会话里,不能存在前端——存在前端就等于没校验。
5.3.7 外部身份与本地账号的绑定
验完 ID token,你拿到的是 sub=idp|usr_1、email=alice@example.com。接下来要把它映射到 TaskHub 的本地账号。这里有三个必须定死的规则:
| 规则 | 做法 | 为什么 |
|---|---|---|
主键用 (issuer, sub) | 唯一索引 | sub 只在单个 IdP 内唯一,换 IdP 会撞 |
| 邮箱仅用于「首次绑定提示」 | 不自动合并 | 邮箱可被改、可被抢注 |
| 首次登录需确认绑定 | 已登录用户点确认,或域名白名单自动加入 | 防「抢注邮箱接管账号」 |
第二条是最容易出安全事故的地方。不要用邮箱自动合并账号:如果 alice@example.com 在 TaskHub 已有本地账号,而某个 IdP 允许用户自助改邮箱,攻击者把 IdP 邮箱改成 alice@example.com 再登录,就会被自动合并进 Alice 的账号。
正确做法分两种情况:
- 用户已登录(在企业设置页点「绑定 SSO」):直接绑定,因为身份已确认。
- 用户未登录(走 SSO 首次进入):不自动合并。如果邮箱已存在,提示「该邮箱已有账号,请先登录后在设置里绑定」。
企业场景还有一个更简洁的规则:按邮箱域名白名单自动加入租户。@acme.com 的员工通过公司 IdP 登录后自动加入 acme 租户,但前提是 IdP 已验证该邮箱(email_verified: true),且该域名已被租户管理员声明。没有这两个前提,任何人都能注册一个 @acme.com 邮箱混进来。
5.3.8 小结
- 第三方登录必须走授权码流程,凭证留在后端;前端只经手一次性的
code。 - OIDC = OAuth2 + 身份层,
id_token是 IdP 用私钥签的 JWT。 - PKCE 让「授权码泄露」从致命降为无害;
state防 CSRF,nonce防重放,三者都不能省。 - 实测全链路跑通:discovery → PKCE → 换 token → JWKS 取公钥 → 验 ID token,
nonce与state均匹配。 x/oauth2靠Content-Type判断响应格式,自建 IdP 不设这个头会报missing access_token。- ID token 的
aud是 client_id,别和自签 access token 的受众抄混。 - 身份主键用
(issuer, sub);邮箱绝不用来自动合并账号,防抢注接管。
认证与授权这一章到此收尾。接下来 TaskHub 的所有查询都要带上租户条件了——这些查询怎么建连接、怎么控制超时、怎么保证不多不少地跑,是下一章的主题。
阅读导航:上一节:5.2 RBAC 与多租户隔离 · 下一节:6.1 连接池与超时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。