为什么个人站长也该关心 OAuth 授权码流程
很多草根站长觉得 OAuth 是「大厂的东西」,自己一个博客一个工具站用不上。但只要你接过一次「用 GitHub 登录」「用微信登录」「第三方帮我读一下阿里云的账单」这类需求,就会立刻撞进 OAuth 的世界。它看起来只是几个 URL 加几个参数,实际上每一个环节都埋着能把数据交到坏人手里的坑。这篇文章不讲抽象概念,只讲我在自己站上接第三方登录和第三方 API 时踩过的具体问题:授权码流程到底哪几步、PKCE 为什么几乎是必选项、state 参数到底防的是什么、回调地址为什么要精确匹配到最后一个斜杠。
先说清楚一件事:OAuth 授权码流程解决的核心问题不是「认证你是谁」,而是「让用户在不在你这里输密码的前提下,授予你一个受限的、可撤销的访问凭证」。这个区别决定了后面所有的设计取舍。把「登录」和「拿到访问权」这两件事混在一起想,是绝大多数实现错误的源头。
一、四个角色,缺一个都跑不起来
规范里有四个角色,用我自己站的例子来对:
- 资源所有者:用户本人。他拥有数据,也是唯一有资格说「我同意」的人。
- 客户端:我的站点或我的脚本。它想要访问用户的数据。
- 授权服务器:GitHub 的
github.com/login/oauth那一套。它负责让用户登录、显示授权页、签发令牌。 - 资源服务器:GitHub 的
api.github.com。它只认令牌,不关心用户是谁怎么同意的。
值得注意的是,授权服务器和资源服务器可以是同一个服务,也可以分开。微信就是典型的分开:授权在 open.weixin.qq.com,拿用户信息要去 api.weixin.qq.com。搞清这一点很重要,因为它决定了你的令牌该往哪个域名发——把授权服务器签发的 token 发到资源服务器之外的域,等于把钥匙交给路人。
二、授权码流程的五步,以及在每一步能出错的地方
完整的授权码流程是五步:
- 客户端把用户浏览器重定向到授权服务器的
/authorize,带上client_id、redirect_uri、scope、state。 - 用户在授权服务器上登录并点「同意」。
- 授权服务器把浏览器重定向回
redirect_uri,URL 上带一个一次性的code。 - 客户端在服务器端(不是浏览器里)用
code加上client_secret去/token换 access_token。 - 客户端带着 access_token 调资源服务器,拿到数据。
为什么非要绕这一圈「先给码再换令牌」,而不直接在第三步就把令牌塞回 URL?因为 URL 会进入浏览器历史、Referer 头、访问日志、CDN 日志,甚至被用户截图发群。授权码是一次性的、有效期通常只有几十秒、而且只能换一次,即使泄露了,攻击者也没有 client_secret,换不到令牌。这就是整个流程的核心价值:把长期凭证的交换过程放在后端,永远不经过浏览器可见的地址栏。
对应地,第 4 步必须在你自己的服务器上做。我见过太多人把 client_secret 写在 JS 里让浏览器去换 token,那不是 OAuth 用法错误,那是把密钥公开挂在网页上,任何打开开发者工具的人都能拿走。
三、state 参数防的不是 CSRF 三个字母,而是「别人的码贴到你头上」
场景是这样的:攻击者自己发起授权,拿到一个属于攻击者账号的 code,然后构造一个链接 https://mysite.com/callback?code=ATTACKER_CODE 发给受害者。受害者点开,你的服务器把攻击者的码换成令牌,绑定到受害者的会话上。此后受害者在你的站里所有操作,都写进了攻击者的账号。这叫授权码注入。
防御办法就是 state:发起授权前生成一个随机串,存进 session,同时放进 /authorize 的参数。回调时比对 URL 里的 state 和 session 里的 state,不一致直接拒绝。写法大概是:
import secrets, hashlib, base64
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
# 回调时
if not secrets.compare_digest(session.pop('oauth_state', ''), req.args.get('state', '')):
abort(400)
三个细节:secrets 而不是 random,前者是密码学安全的;compare_digest 而不是 ==,避免时序侧信道;pop 而不是 get,保证 state 一次性,用过即销毁,堵住重放。
四、PKCE:没有 client_secret 的客户端也必须用它
公共客户端(单页应用、手机 App、桌面程序)没法安全保存 client_secret,因为代码在用户机器上。这时攻击者只要拿到 code 就能直接换令牌,前面说的「码只有一次性」的保护就失效了。PKCE(Proof Key for Code Exchange,读作 pixy)就是补这个洞。
做法是发起授权前生成一个高熵随机串叫 code_verifier,把它做 SHA-256 再 base64url 编码得到 code_challenge,授权请求带上 challenge 和 code_challenge_method=S256。换令牌时把原始 verifier 一起带上,授权服务器自己算一遍哈希比对。
verifier = secrets.token_urlsafe(64) # 43~128 字符
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()
关键点:code 在传输过程里可能泄露,但 verifier 从不经过浏览器地址栏,所以攻击者拿到码也换不到令牌。现在的实践是——即使你有 client_secret,也照样加 PKCE。规范里对机密客户端说是「推荐」,但如果你的回调地址是 app 自定义 scheme 或者 localhost,那基本就是「必须」。
五、redirect_uri 必须精确匹配,不能用前缀
这是授权码泄露最常见的入口。假设授权服务器允许「前缀匹配」,注册的是 https://mysite.com/callback,攻击者就能用 https://mysite.com/callback/../../attacker 这种路径穿越,或者干脆用 https://mysite.com.evil.com/callback,让授权服务器把码送到他的域名下。
正确做法是注册时填写完整地址(含 scheme、host、port、path,甚至尾部斜杠),服务端用字符串全等比较。我自己的实现里会把注册表做成一张精确的白名单表,不做任何通配。另外要留意:开发环境用 http://localhost:3000/cb,上线后一定要把这条删掉,否则它就是一条永久的后门。
六、scope 要最小,令牌要能撤
很多人接入时图省事,直接把 repo、admin:org 这种大范围权限全勾上。这是拿用户的全部身家换自己半小时的方便。原则很简单:需要读公开信息就只要 read:user,需要写一个仓库就只对那个仓库申请权限。GitHub 的细粒度 token 就是为此设计的。
另外,务必把拿到的 access_token 和 refresh_token 当作密码对待:加密存储(不要明文进数据库、更不要进 Git)、日志里脱敏、能撤销的平台就定期撤销重发。我在自己的运维脚本里规定了一条:任何打印请求的地方,先把 Authorization 头和 access_token= 后面的值替换成 ***,一个正则在入口统一处理。
七、一个能跑的最小实现,以及四个必查项
把上面的东西拼起来,服务端只需要三个路由:/login 生成 state 和 PKCE 并重定向;/callback 校验 state、用 code+verifier 换令牌、取用户信息、建会话;/logout 清会话并(可选)调撤销端点。
上线前我固定跑四个检查,缺一不可:
- 把
/callback的 URL 手工改掉一个字符,必须返回 400 而不是 500 或跳登录页——说明 state 校验真的在执行。 - 用同一个 code 换两次令牌,第二次必须失败——验证一次性。
- 在浏览器里抓一次完整流程,确认任何响应体、任何 URL 里都不出现
client_secret和access_token。 - 把
redirect_uri换成带../的变体,确认授权服务器拒绝(这一步测的是对方,但能验证你注册的地址够严格)。
八、小结:OAuth 的错误几乎都是「把不该给浏览器的给了浏览器」
回头看这一整套流程,它的每一个设计都在做同一件事:把长期凭证藏起来,把短期凭证做成一次性的,把传送通道限制在精确匹配的地址上。授权码是短期的、一次性的;state 是短期的、一次性的;PKCE 的 verifier 从不经过前端;client_secret 只存在服务器上。只要你在实现时反复问一句「这个东西会不会出现在地址栏或前端代码里」,绝大多数 OAuth 事故都能提前避免。
对个人站长来说,接入第三方登录不是难事,难的是接完之后不留后门。精确的 redirect_uri、最小 scope、可撤销的令牌、日志脱敏,这四件事做扎实,比记住规范里的每一条都管用。