Voocii.

OAuth + PKCE认证流程简析

rick-hayekrick-hayek

在互联网世界里,你一定见过“使用微信登录”、“使用 GitHub 登录”的按钮。这种不需要输入第三方账号密码就能实现安全登录的技术,背后正是基于 OAuth 2.0 协议。

然而,随着移动 App 和单页应用(SPA)的普及,传统的 OAuth 2.0 暴露出了致命的安全漏洞。为了堵上这个漏洞,业界引入了一个强力外援——PKCE(Proof Key for Code Exchange)

今天,我简单拆解一下认证流程,看懂 OAuth 2.0 + PKCE 是如何确保数据安全的。


1. 什么是 OAuth 2.0?

简单来说,OAuth 2.0 是一个“授权”协议。 它允许一个应用(客户端)在不获取用户密码的情况下,代表用户去访问另外一个服务(资源服务器)上的特定数据。

一个生活中的例子:外卖小哥与电子门禁

想象一下,你住在一家高级公寓。外卖小哥(客户端 App)要把餐送到你家门口(访问数据),但公寓大门有电子门禁(资源服务器)。

  • 糟糕的做法:你把门禁密码(用户密码)直接告诉外卖小哥。这很危险,因为小哥以后随时能刷密码进你家。
  • OAuth 2.0 的做法:你给外卖小哥发送一个临时二维码(Access Token,访问令牌)。小哥在门口机器上一刷,大门识别出这是你授权的、且只在 10 分钟内有效的凭证,于是放行。

2. 经典的“授权码模式 (Code Flow)”是怎样的?

在 OAuth 2.0 的众多模式中,授权码模式(Authorization Code Flow)是最安全、最常用的一种。它之所以安全,是因为它采用了“两步走”的策略:

  1. 第一步:拿授权码(Code) 应用先带着用户跳转到登录中心(授权服务器)。用户登录并同意授权后,服务器不会直接给应用令牌,而是先给应用一个临时的、很快就过期的授权码(Code)
  2. 第二步:用码换令牌(Token) 应用在后台把这个授权码连同只有它自己知道的“客户端密钥(Client Secret)”一起发送给服务器,最终换回真正的访问令牌(Access Token)。

3. 传统授权码模式有什么缺陷?

既然“两步走”看起来天衣无缝,为什么还要引入 PKCE 呢?

因为时代变了。过去的应用大多运行在安全的服务器后端(Confidential Client),黑客拿不到服务器里的“客户端密钥”。但现在的应用大量运行在手机 App、桌面客户端或浏览器单页应用(Public Client,公共客户端)中。

对于这些“公共客户端”,传统的授权码模式存在一个致命缺陷:

授权码拦截攻击 (Authorization Code Interception Attack)

在手机系统(iOS/Android)或浏览器中,App 之间通常使用“自定义 URL Schema”(例如 myapp://callback)来接收授权服务器返回的授权码。

  1. 恶意应用冒充:黑客可以在用户的手机上安装一个恶意应用,并无耻地注册相同的 myapp://callback
  2. 拦截授权码:当你在正版 App 里完成登录,授权服务器返回授权码时,手机系统可能会误把这个授权码传给黑客的恶意应用。
  3. 由于没有秘密可言:因为这是个手机 App,正版应用里根本没办法安全地隐藏 Client Secret(反编译一下就曝光了)。这意味着黑客的恶意应用同样不需要密钥,直接拿着偷来的授权码就能去换取用户的访问令牌(Access Token)!用户的数据瞬间失窃。

4. 救场英雄:什么是 PKCE?

为了解决公共客户端无法安全保存密钥、授权码容易被拦截的问题,IETF 推出了 PKCE(全称 Proof Key for Code Exchange,读作 pixie)。

PKCE 的核心思想是: 既然静态的“客户端密钥”容易泄露,那我们就干脆不要静态密钥了!改为由客户端动态生成一个一次性的、无法逆推的临时暗号

在发起登录前,应用在本地生成两个东西:

  1. Code Verifier(验证码):一个随机生成的超长复杂字符串。这相当于“原始钥匙”,留在 App 内存中,绝不走网络传输。
  2. Code Challenge(挑战码):对上面的字符串进行 SHA-256 哈希加密,再经过 Base64URL 编码处理。这相当于“钥匙的指纹”。

5. Code + PKCE 的完整认证流程拆解

有了 PKCE 之后,整个认证流程升级为了更为严密的“密室对暗号”模式。我们根据交互规范,将其拆解为以下 5 大核心步骤

+--------+                               +---------------+
|        |--(A)- 发起登录 (Challenge)---->|               |
|        |                               |               |
|        |<-(B)- 返回授权码 (Code)--------|  授权服务器   |
| 客户端 |                               |               |
|  App   |--(C)- 换令牌 (Code+Verifier)-->|               |
|        |                               +---------------+
|        |<-(D)- 校验成功,发放 Token-----|   令牌端点    |
+--------+                               +---------------+

第一步:客户端本地准备

客户端(App)在内存中随机生成一个高熵字符串 code_verifier,接着计算其 SHA-256 哈希值得到 code_challenge

第二步:重定向至授权端点(携带“指纹”)

客户端引导用户跳转到授权服务器。在发送的请求中,不仅包含传统的参数(如 client_idredirect_uri),还带上了钥匙的指纹和加密算法:

GET /authorize?
  response_type=code&
  client_id=spa-client&
  redirect_uri=https://app.example.com/callback&
  scope=openid profile email&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256

服务器操作:授权服务器让用户输入账号密码登录。同时,服务器在后台把这个 code_challenge 牢牢记在自己的小本本上。

第三步:用户同意,服务器返回授权码

用户登录并点击“同意授权”后,授权服务器通过回调地址将一次性的 code 返回给客户端。

302 Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA

第四步:用授权码换令牌(携带“原始钥匙”)

客户端拿到 code 后,向服务器的令牌端点(Token Endpoint)发起 POST 请求。关键点来了:这次它把一直藏在内存里的“原始钥匙” code_verifier 发了过去:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https://app.example.com/callback&
client_id=spa-client&
code_verifier=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

第五步:关键校验与令牌签发

令牌端点收到请求后,做出一个数学验证:将客户端传过来的 code_verifier 用同样的 S256 算法计算一遍,看结果是否等于第二步收到的 code_challenge

  • 如果相等:证明“现在来换令牌的 App”和“第一步发起登录的 App”是同一个应用!服务器大方发放 access_token
  • 如果不相等:拒绝请求。

6. 为什么 PKCE 能完美防御拦截攻击?

我们重新代入黑客的视角看看:

  1. 黑客的恶意应用利用系统漏洞,确实在第三步半路拦截到了授权码 code=SplxlOBeZQQYbYS6WxSbIA
  2. 黑客欣喜若狂,试图把这个 code 发给服务器换取令牌。
  3. 但是! 服务器的令牌端点无情地要求:“请交出你的 code_verifier(原始钥匙)。”
  4. 黑客傻眼了,因为 code_verifier 自始至终只存在于正版 App 的内存中,根本没在网络上出现过。黑客手里只有第二步公开的 code_challenge(指纹),由于 SHA-256 算法是不可逆的,黑客穷尽一生也无法从指纹反推出原始钥匙。

校验失败,黑客手里的授权码直接变成废纸一张!

延伸思考:如果黑客试图拦截 code_verifier 呢?

有些同学可能会问:“既然授权码和 code_challenge 能被拦截,难道黑客就不能拦截第四步的 code_verifier 吗?”

实际上,截获 code_verifier 的难度与截获 code_challenge 相比有着天壤之别

1. 传输通道与留痕差异 (核心防线)

  • code_challenge (第二步:高暴露风险):通过浏览器重定向 (GET) 传输,参数拼接在 URL 后。即使有 HTTPS,URL 仍会被记录在浏览器历史记录、反向代理(如 Nginx)访问日志和系统日志中。在移动端,恶意应用还可以注册相同的 Custom URL Schemes 直接在系统层面拦截它。
  • code_verifier (第四步:高安全隔离):通过后台 POST 请求体发送。数据在 HTTPS 保护下是强加密的,且不会在浏览器历史或系统日志中留痕。code_verifier 从生成到发送,始终保存在正版 App 的私有内存空间中。除非设备被植入木马直接读取运行内存(RAM),否则外部应用无法触及。
特性code_challenge (第二步)code_verifier (第四步)
传输位置URL 参数 (GET)请求体 (POST Body)
日志留痕会留在浏览器历史、服务器日志中不会留痕
网络劫持难度相同(均靠 HTTPS 防御)相同(均靠 HTTPS 防御)
本地/系统劫持难度极易(恶意 App 拦截、读日志)极难(需要获取进程内存权限)

2. 如果 code_verifier 真的被截获了会怎样?

如果在换取 Token 时 code_verifier 真的被截获(通常意味着 HTTPS 加密已被彻底破解),OAuth 2.0 协议和认证服务器也设计了相应的安全兜底机制:

  • 授权码一次性:授权码在设计上只能使用一次且有效期极短(通常 1~10 分钟)。黑客必须抢在正规客户端之前发起请求,否则授权码失效。
  • 防重放与双向作废:如果认证服务器发现同一个授权码被提交了第二次,服务器会立刻让该授权码对应的所有 Token 失效(双向作废),并触发安全风控,强制中断当前会话。

因此,PKCE 的精妙之处在于默认 code_challenge 是一定会暴露的,其核心设计就是为了保护那个“只藏在内存中、只走 POST 请求”的 code_verifier


7. 可交互动画演示 (PKCE Flow Animation)

你可以通过以下链接查看完整的 PKCE 交互流程动画演示:

👉 点击此处在新页面中打开独立动画演示


总结

OAuth 2.0 的 Code + PKCE 模式,通过“前向发送哈希指纹,后向提交原始钥匙”的精妙设计,在不需要静态密钥的前提下,完美确认了客户端的身份一致性。

如今,PKCE 已经不仅仅是手机 App 和单页应用的专属。在最新的 OAuth 2.1 推荐规范中,所有类型的应用(包括拥有安全后端的服务器应用)都建议强制开启 PKCE,它已经成为了现代互联网安全认证的不二选择。

Comments (0)

No comments yet. Be the first!

Leave a comment