有些网站(比如本站),你登陆的时候,界面上有“使用微信登录”、“使用 GitHub 登录”等按钮,但这个网站本身却不是微信或者Github所拥有。那为什么可以使用微信或GitHub的账号来登陆一个第三方网站,而不需要你注册一个该网站的用户来登陆?这种不需要输入第三方账号密码就能实现安全登录的技术,背后就是基于 OAuth 2.0 协议。
然而,随着移动 App 和单页应用(SPA)的普及,传统的 OAuth 2.0 暴露出了致命的安全漏洞。为了堵上这个漏洞,业界引入了一个强力外援——PKCE(Proof Key for Code Exchange)。
我简单拆解一下认证流程,看懂 OAuth 2.0 + PKCE 是如何确保数据安全的。
简单来说,OAuth 2.0 是一个“授权”协议。 它允许一个应用(客户端)在不获取用户密码的情况下,代表用户去访问另外一个服务(资源服务器)上的特定数据。
想象一下,你住在一栋别墅。外卖小哥(客户端 App)要把餐送到你家门口(访问数据),但别墅有围墙,围墙大门有电子门禁(资源服务器)。
在 OAuth 2.0 的众多模式中,授权码模式(Authorization Code Flow)是最安全、最常用的一种。它之所以安全,是因为它采用了“两步走”的策略:
既然“两步走”看起来天衣无缝,为什么还要引入 PKCE 呢?
因为时代变了。过去的应用大多运行在安全的服务器后端(Confidential Client),黑客拿不到服务器里的“客户端密钥”。但现在的应用大量运行在手机 App、桌面客户端或浏览器单页应用(Public Client,公共客户端)中。
对于这些“公共客户端”,传统的授权码模式存在一个致命缺陷:
在手机系统(iOS/Android)或浏览器中,App 之间通常使用“自定义 URL Schema”(例如 myapp://callback)来接收授权服务器返回的授权码。
myapp://callback。Client Secret(反编译一下就曝光了)。这意味着黑客的恶意应用同样不需要密钥,直接拿着偷来的授权码就能去换取用户的访问令牌(Access Token)!用户的数据瞬间失窃。为了解决公共客户端无法安全保存密钥、授权码容易被拦截的问题,IETF 推出了 PKCE(全称 Proof Key for Code Exchange,读作 pixie)。
PKCE 的核心思想是: 既然静态的“客户端密钥”容易泄露,那我们就干脆不要静态密钥了!改为由客户端动态生成一个一次性的、无法逆推的临时暗号。
在发起登录前,应用在本地生成两个东西:
有了 PKCE 之后,整个认证流程升级为了更为严密的“密室对暗号”模式。我们根据交互规范,将其拆解为以下 5 大核心步骤:
| 客户端 App (Client) | 交互步骤 (Sequence) | 授权/令牌服务器 (Server) |
|---|---|---|
生成 code_verifier 和 code_challenge | ||
| (A) 发起授权请求 携带 code_challenge & code_challenge_method \longrightarrow | ||
| 用户登录并同意授权 | ||
\longleftarrow (B) 返回授权码返回临时 code (Authorization Code) | ||
| (C) 换取令牌 (Token Request) 发送 code + 原始 code_verifier \longrightarrow | ||
校验 code_verifier 哈希值是否与最初的 code_challenge 一致 | ||
\longleftarrow (D) 校验成功,发放令牌返回 Access Token & ID Token |
客户端(App)在内存中随机生成一个高熵字符串 code_verifier,接着计算其 SHA-256 哈希值得到 code_challenge。
客户端引导用户跳转到授权服务器。在发送的请求中,不仅包含传统的参数(如 client_id、redirect_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。
access_token。我们重新代入黑客的视角看看:
code=SplxlOBeZQQYbYS6WxSbIA。code 发给服务器换取令牌。code_verifier(原始钥匙)。”code_verifier 自始至终只存在于正版 App 的内存中,根本没在网络上出现过。黑客手里只有第二步公开的 code_challenge(指纹),由于 SHA-256 算法是不可逆的,黑客穷尽一生也无法从指纹反推出原始钥匙。校验失败,黑客手里的授权码直接变成废纸一张!
有些同学可能会问:“既然授权码和 code_challenge 能被拦截,难道黑客就不能拦截第四步的 code_verifier 吗?”
实际上,截获 code_verifier 的难度与截获 code_challenge 相比有着天壤之别。
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 拦截、读日志) | 极难(需要获取进程内存权限) |
code_verifier 真的被截获了会怎样?如果在换取 Token 时 code_verifier 真的被截获(通常意味着 HTTPS 加密已被彻底破解),OAuth 2.0 协议和认证服务器也设计了相应的安全兜底机制:
因此,PKCE 的精妙之处在于默认 code_challenge 是一定会暴露的,其核心设计就是为了保护那个“只藏在内存中、只走 POST 请求”的 code_verifier。
你可以通过以下链接查看完整的 PKCE 交互流程动画演示:
OAuth 2.0 的 Code + PKCE 模式,通过“前向发送哈希指纹,后向提交原始钥匙”的精妙设计,在不需要静态密钥的前提下,完美确认了客户端的身份一致性。
如今,PKCE 已经不仅仅是手机 App 和单页应用的专属。在最新的 OAuth 2.1 推荐规范中,所有类型的应用(包括拥有安全后端的服务器应用)都建议强制开启 PKCE,它已经成为了现代互联网安全认证的不二选择。
暂无评论,快来抢沙发吧!