OAuth 2.0 到底解决了什么问题:一文讲清授权码 + PKCE 完整流程
在 OAuth 2.0 出现之前,如果你想让一个第三方应用读取你的 Twitter 时间线,唯一的办法是把 Twitter 用户名和密码交给对方——不是令牌,也不是权限受限的凭据,而是你真正的密码。
应用会以你的身份登录,拿到需要的东西就算完事(如果你运气好,它不会把密码存在某个不靠谱的地方)。而用户几乎天天在这么做:Twitter 客户端、邮件阅读器、通讯录导入工具、日历同步服务,一整类应用都是这个模式。每一个都在手里攥着一堆不断增长的用户密码。
这被叫作「密码反模式」(password anti-pattern),后果有多糟不必多说。只要第三方应用被攻破,它经手过的每一个用户账号都会跟着一起沦陷。用户想只吊销某一个应用的权限,只能把密码在所有网站都改一遍。而且应用拿到的是完整账号权限,而不是它真正需要的那一小部分。
OAuth 2.0 的三个核心属性
核心变化是:用户只在自己信任的服务方(Google、GitHub 等)上完成认证,第三方应用永远看不到密码;服务方签发一个令牌(token),而这个令牌有三个关键性质:
- 有范围(Scoped) —— 只授予用户明确批准的特定权限。
- 有时效(Time-limited) —— 会过期,通常在一小时以内。
- 可撤销(Revocable) —— 用户随时可以在账号设置里一键作废。
换句话说,整个协议的其余部分——授权码交换、刷新令牌、PKCE、作用域、发现机制——都只是为了服务这一条原则而存在的机械装置:让用户在不交出密码的前提下,授予第三方应用有范围、有时限、可撤销的访问权。
现代标准流程:Authorization Code + PKCE
如今的标准做法是「授权码 + PKCE」(Authorization Code with PKCE),一共五步。
1. 应用把用户重定向到授权服务器
https://auth.example.com/oauth/authorize
?response_type=code
&client_id=budgettrack-prod
&redirect_uri=https://budgettrack.com/callback
&scope=calendar.read
&state=xY7f3aQ2
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
2. 用户在授权服务器自己的域名上完成认证
第三方应用完全看不到这个过程发生——密码不会越过网络流向授权服务器以外的任何人。
3. 授权服务器带着一个短时效授权码重定向回来
https://budgettrack.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
4. 应用用授权码换取令牌(服务端到服务端)
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
5. 授权服务器返回令牌
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "v1.MRn7...",
"scope": "calendar.read"
}
之后应用就用 `Authorization: Bearer <token>` 调用 API,直到令牌过期。用户密码自始至终没有参与其中任何一步。
那些「魔法参数」到底防住了什么
上面的流程里有四个安全关键参数,大多数教程一带而过。每一个都在堵一个具体的攻击面。
state=xY7f3aQ2—— 防 CSRF
应用生成一个随机值,带在授权请求里发出去,回调时再校验回来的值是否一致。否则,攻击者可以诱骗一个已登录的用户去完成攻击者自己的 OAuth 流程,把攻击者的账号绑定到用户的应用会话上。不校验 state 是现实中最高频的 OAuth 缺陷之一。
code_challenge+code_verifier—— PKCE(Proof Key for Code Exchange)
应用先生成随机的 code_verifier,用 SHA-256 哈希得到 code_challenge 随第 1 步发出,再在第 4 步用原始 code_verifier 换取令牌。这样即便攻击者截获了授权码,没有匹配的 code_verifier,授权码就一文不值。移动应用和 SPA 必须使用,其他场景也强烈建议。
redirect_uri=https://budgettrack.com/callback—— 防授权码劫持
授权服务器只会把授权码发到这个预先注册过的精确 URL。攻击者无法中途换成 https://evil.com/callback 来截走授权码。
- 短时效授权码(第 3 步)—— 防重放
授权码是一次性的,通常 10 秒到 2 分钟就过期。即使它出现在日志或网络抓包里,之后也无法再利用。
四个小参数,各自封住一类攻击,合起来才让 OAuth 2.0 足以支撑互联网上每一个「使用 Google 登录」按钮。
为什么作用域(scope)才是关键
scope=calendar.read 的含义就是:这个应用只能读取日历。仅此而已。它不能删除日程,不能发送邮件,不能访问通讯录,不能以用户身份发帖。
如果这个应用明天被攻破,攻击者拿到的只是日历的读取权限——糟糕,但边界清晰。这和 OAuth 之前的世界形成鲜明对比:那时攻击者拿到的是应用触碰过的每个服务的完整账号权限,而且在所有平台上都是如此。
安全的核心玩法就是把「一切」变成「某一个具体的东西」,而作用域正是 OAuth 2.0 在协议层面实现这一点的手段。
给开发者的落地建议
如果你正在实现或维护一套 OAuth 2.0 集成,这份流程可以压缩成几条可执行的检查项:
- 选型维度一:流程形态 —— 优先采用 Authorization Code + PKCE,而不是 Implicit(
response_type=token)。Implicit 模式会把令牌暴露在浏览器地址栏和重定向链路上,已被 OAuth 2.1 明确废弃。 - 选型维度二:令牌时效 —— 访问令牌按分钟甚至秒级过期(示例中
expires_in为 3600 秒),用刷新令牌(refresh_token)续期,而不是发长期令牌。 - 选型维度三:权限粒度 —— 按最小权限拆分 scope(如
calendar.read与calendar.write分开申请),并在界面上逐项展示。 - 落地步骤 —— ① 生成并持久化
state(并与浏览器会话绑定),回调时强制校验;② 每次授权生成一次性code_verifier(S256)并与会话绑定;③ 在服务端预注册redirect_uri,逐字符校验;④ 授权码只使用一次,服务端换令牌;⑤ 令牌按 scope 落库,提供用户可见的「撤销授权」入口,让用户可以只吊销单个应用。


评论