主题
OAuth2 与认证鉴权
一句话:OAuth2 是授权委托框架,让资源所有者把「有限权限、有限时间」的访问权交给客户端;它回答的是「你能做什么」,不回答「你是谁」——身份层要由 OIDC 的 ID Token 补上。
适用版本:OAuth 2.0(RFC 6749)、Bearer Token(RFC 6750)、PKCE(RFC 7636)、令牌撤销(RFC 7009)、令牌内省(RFC 7662)、OIDC Core 1.0;OAuth 2.1 仍为草案。协议默认值与实现细节以官方文档为准。
为什么需要
第三方应用要读用户数据,最直接的做法是让用户交出密码,但这带来三个问题:密码泄露面扩大、应用拿到全部权限、用户无法单独撤销某一个应用。OAuth2 把「用户凭据」和「受限授权」分开:用户在授权服务器登录,客户端只拿到一个有范围、有期限的 access token,资源服务器只认这个令牌。
但 access token 只证明「持有者被授权访问某个资源」,不证明用户身份。把它当登录态会掉进三个坑:令牌被复制就能冒用、不同客户端的令牌语义不同、资源服务器无法确认「登录的是谁」。OIDC 在 OAuth2 之上增加 ID Token(JWT)和 UserInfo,才构成认证协议。
核心机制
角色与令牌
| 角色/令牌 | 作用 | 关键约束 |
|---|---|---|
| Resource Owner | 授权的人 | 只在授权服务器输入凭据 |
| Client | 代用户访问资源 | 分公共客户端(无 secret)与机密客户端 |
| Authorization Server | 认证用户、签发令牌 | 持有签名私钥与客户端注册信息 |
| Resource Server | 校验令牌、返回资源 | 只信签名与 claims,不信任请求来源 |
| access token | 访问资源的凭证 | 短生命周期、Bearer 语义、必须走 TLS |
| refresh token | 换取新 access token | 只发授权服务器,可轮换、可撤销 |
| authorization code | 授权码流程的中间票据 | 一次性、短寿命、绑定 redirect_uri 与 PKCE |
| ID Token(OIDC) | 证明用户身份 | JWT,含 iss/aud/sub/nonce |
授权码 + PKCE
text
用户 → 客户端 → 授权服务器 /authorize
client_id, redirect_uri, response_type=code, scope, state,
code_challenge, code_challenge_method=S256
授权服务器 → 用户:登录 + 同意授权
授权服务器 → 客户端:302 redirect_uri?code=...&state=...
客户端 → 授权服务器 /token
grant_type=authorization_code, code, redirect_uri, client_id,
code_verifier(机密客户端再加 client_secret 或 private_key_jwt)
授权服务器 → 客户端:access_token, refresh_token, id_token
客户端 → 资源服务器:Authorization: Bearer <access_token>四个参数各自防一类问题,缺一个就留下缺口:
state:客户端生成随机值,随授权请求发出,回调时原样比对。它把回调绑定到发起请求的浏览器会话,防登录 CSRF 和授权码注入。- PKCE:客户端生成
code_verifier,把BASE64URL(SHA256(verifier))作为code_challenge发出;换令牌时再出示 verifier。授权码即使被截获,没有 verifier 也换不到令牌。公共客户端没有client_secret,PKCE 是唯一能把授权码绑定到发起方的手段;OAuth 2.1 草案要求授权码流程使用 PKCE,plain方法仅为兼容旧实现。 redirect_uri白名单:注册时逐条登记,回调时做完整字符串精确匹配,不允许通配符、路径穿越或开放重定向。否则攻击者能把授权码送到自己的域名。nonce(OIDC):请求时带上,ID Token 必须原样回传;客户端校验它,防 ID Token 重放。state防的是授权响应被替换,nonce防的是身份断言被重放,两者不能互相替代。
授权码一次性使用,RFC 6749 建议寿命不超过 10 分钟,工程上通常更短。隐式模式把令牌放进 URL fragment,会留在浏览器历史和 Referer 中,已废弃;密码模式让客户端接触用户密码,OAuth 2.1 已移除。服务间无用户参与时用客户端凭证模式,此时没有 refresh token,客户端本身就是资源所有者;开放平台若不用 OAuth2,常用 AK/SK + HMAC(method+path+timestamp+body)做请求签名,timestamp 窗口 5 分钟、nonce 去重防重放。
令牌生命周期
access token 的寿命没有协议默认值,15 分钟是常见设计值而不是规范要求;原则是「泄露后的暴露窗口」可接受。refresh token 寿命更长,因此推荐轮换(OWASP 建议,不是协议强制):每次刷新都返回新的 refresh token,旧的立即失效;如果旧 token 再次被使用,说明它已泄露,授权服务器应撤销整个 token family 并强制重新登录。用户登出、改密、权限变更时调用撤销接口(RFC 7009),不要只删客户端本地存储。
JWT 与 opaque token 的取舍在于校验位置:
| 维度 | JWT(自包含) | Opaque token |
|---|---|---|
| 校验 | 资源服务器本地验签 | 调授权服务器 introspect(RFC 7662) |
| 撤销 | 困难,只能等过期或维护黑名单 | 立即生效 |
| 性能 | 无远程调用 | 每次多一次网络请求 |
| 适合 | 短寿命、高并发、可接受延迟撤销 | 需要即时封禁、令牌内容不透明 |
JWT 的 Payload 只是 Base64URL 编码,不是加密,任何人都能读;不要放手机号、身份证号等敏感信息,需要保密就用 JWE。JWT 的三个常见攻击面是:alg=none(服务端必须用配置的算法白名单验签,绝不信任 Header 里的 alg)、HS256 弱密钥离线爆破(密钥至少 256-bit 随机值,高安全场景用 RS256/ES256 私钥签名、公钥验签,具体固定哪一种由授权服务器注册决定)、客户端存储不当(access token 放内存,refresh token 放 HttpOnly; Secure; SameSite Cookie,避免 localStorage 被 XSS 读取)。
谁在什么时候校验什么
| 时机 | 执行方 | 必须校验 |
|---|---|---|
/authorize 请求进入 | 授权服务器 | client_id 已注册;redirect_uri 精确匹配;response_type、scope 合法;把 state(回显)与 PKCE challenge 绑定到本次授权请求 |
| 用户登录 | 授权服务器 | 用户凭据与 MFA;这一步是认证,不是 OAuth2 授权 |
| 回调到达客户端 | 客户端 | state 与本地会话一致;无 error 参数;code 存在;不信任回调里的任何用户信息 |
/token 换令牌 | 授权服务器 | code 未使用且未过期;redirect_uri 与授权请求一致;code_verifier 与 challenge 匹配;机密客户端完成客户端认证 |
| 每次资源请求 | 资源服务器 | 签名(JWKS 按 kid 取公钥)、算法白名单、iss 精确匹配、aud 包含自身、exp/nbf(允许小幅时钟偏移)、scope 足够;opaque token 走 introspect |
| 收到 ID Token | 客户端 | 签名、iss、aud 包含自己的 client_id、exp、nonce;需要时校验 at_hash |
| 刷新令牌 | 授权服务器 | refresh token 有效且未被轮换;检测重复使用;校验客户端身份;新 scope 不得超出原授权 |
关键点:网关验签不等于业务授权。网关只能确认令牌有效,资源服务器仍要按 sub/租户/角色做对象级权限判断,否则一个合法令牌就能越权读别人的数据。
最小上手
bash
# 1. 生成 PKCE verifier 与 S256 challenge(OpenSSL 3.x)
verifier=$(openssl rand -base64 48 | tr -d '\n=+/' | cut -c1-64)
challenge=$(printf %s "$verifier" | openssl dgst -sha256 -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '=')
state=$(openssl rand -hex 16)
echo "https://auth.example.com/authorize?response_type=code&client_id=web\
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&scope=openid%20profile\
&state=$state&code_challenge=$challenge&code_challenge_method=S256"
# 2. 浏览器完成授权后,用回调里的 code 换令牌(公共客户端不带 secret)
curl -s -X POST https://auth.example.com/token \
-d grant_type=authorization_code -d code="$CODE" \
-d redirect_uri=https://app.example.com/cb -d client_id=web \
-d code_verifier="$verifier"
# 3. 看授权服务器公布了哪些签名密钥
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[] | {kid, alg}'资源服务器侧用库验签,不要手写解析:
python
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(
token, key.key, algorithms=["RS256"],
audience="https://api.example.com",
issuer="https://auth.example.com",
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)algorithms 是白名单,PyJWT 会校验 exp/nbf/aud/iss;kid 找不到时应刷新 JWKS 缓存一次,而不是跳过验签。
生产动作
- JWKS 与轮换:缓存公钥并设置 TTL;遇到未知
kid刷新一次;新旧密钥并存至少一个 access token 寿命,避免轮换瞬间全员 401。 - 时钟偏移:
exp/nbf允许 60 秒左右的偏移是工程值,不是协议默认;服务器统一 NTP,时钟漂移会表现为大面积 401。 - 令牌不外泄:不把 access token 放 URL、日志、埋点;Bearer token 谁拿到谁就能用,TLS 是底线。
- 撤销路径:登出、改密、角色变更、检测到 refresh token 重复使用,都要撤销 refresh token 及相关会话;紧急封禁可叠加
jti黑名单,但别把黑名单当常规撤销方案。 - 权限收口:scope 按最小权限授予,
aud按 API 区分;资源服务器做对象级校验,网关只做第一道。 - 监控:401 突增先查密钥轮换与时钟;403 突增先查权限策略变更;refresh 失败率与授权码重复使用要单独告警。
边界
- OAuth2 不是登录协议。要做「用某平台登录」,用 OIDC;只拿 access token 当身份凭证,等于把「有权限」误当成「是本人」。
- JWT 不适合需要即时撤销的长寿命会话。无状态是优点也是代价;需要随时踢人就用短 token + refresh 轮换,或改用 opaque token + introspect。
- PKCE 不保护被攻破的客户端。它能防授权码截获,防不了恶意 App 自己把 verifier 交出去。
- SSO 不等于 OAuth2。SSO 是「一次认证、多系统复用登录态」,可以用 OIDC、SAML 或 CAS 实现;OAuth2 只是授权委托,不能单独完成 SSO。
- 各家「第三方登录」有差异。微信、飞书等平台的 scope、用户信息接口和 ID Token 语义并不完全等同 OIDC,接入前以对方文档为准。
讲清楚
OAuth2 解决的是「用户不想把密码交给第三方,但仍要让第三方访问一部分数据」。它把授权拆成授权服务器、客户端、资源服务器三方:用户在授权服务器登录,客户端拿到短期 access token,资源服务器验令牌后放行。授权码模式加 PKCE 是关键,code 一次性、绑定 redirect_uri 和 verifier,state 防回调被替换,OIDC 再用 ID Token 的 nonce 和 aud 补上身份层。access token 要短,refresh token 要轮换并能在泄露时撤销;JWT 校验必须走 JWKS 和算法白名单,并核对 iss、aud、exp。它最大的边界是:OAuth2 只回答「能访问什么」,不回答「是谁」,把它当登录协议是最常见的误用。