告别教科书:从百万日活社区重构看OAuth 2.1与2.0的取舍

我们团队在去年接手了一个百万日活的技术社区系统重构,原系统的第三方登录模块是基于 2015 年的 OAuth 2.0 标准(RFC 6749)写的,代码里还留着当年为了兼容旧版 Safari 写的隐式模式(Implicit Flow)分支。第一次看代码的时候我就知道,这次重构不仅要做业务迁移,更要在安全标准上做一次彻底的升级。

当时摆在面前的选择很明确:继续沿用稳定的 OAuth 2.0,还是直接跟进当时还是草案阶段的 OAuth 2.1(最新草案是 draft-ietf-oauth-v2-1-12,2024 年 3 月更新)。我没有盲目追新,而是拉了一份真实的安全扫描报告出来——过去一年里,我们的安全团队拦截了 17 次针对隐式模式的令牌窃取尝试,其中有 3 次成功拿到了短期有效的 Access Token,虽然因为令牌有效期只有 10 分钟没造成大损失,但这已经是定时炸弹。

我决定直接放弃 OAuth 2.0 里的隐式模式和密码模式。OAuth 2.1 草案里明确废弃了这两种模式,不是没有道理的。隐式模式当年是为了前端 JavaScript 应用设计的,令牌直接通过 URL Hash 返回,我见过生产环境里因为前端路由配置错误,把令牌打进 Nginx 日志里的事故。密码模式更危险,第三方应用直接拿用户密码换令牌,我们社区 2018 年就有过一次因为合作方服务器被拖库,导致 2000 多个用户密码泄露的教训。

剩下的只有授权码模式(Authorization Code)和客户端凭证模式。客户端凭证模式我们用在了内部服务间调用,比如社区的动态服务拉取用户的标签数据,这个没争议。核心争议在授权码模式要不要上 PKCE。

我翻了 OAuth 2.1 的草案说明,PKCE(Proof Key for Code Exchange)已经被列为强制要求。我们社区有 30% 的流量来自移动端 APP,以前的做法是原生 APP 直接用授权码换令牌,没做 PKCE。去年 10 月我们做过一次压测,模拟了 5000 QPS 的授权请求,发现没有 PKCE 的情况下,授权码在传输过程中如果被中间人拦截,攻击者可以直接用这个码换到令牌。当时我们安全团队用 Burp Suite 演示了这个攻击,整个过程不到 30 秒,这让我下定决心:PKCE 必须上。

下面是我们在重构时写的 PKCE 生成和验证的核心代码,基于 Node.js 18.x 环境,用的是 crypto 原生模块,没引入额外的第三方库:

const crypto = require('crypto'); /** * 生成 PKCE 的 code_verifier 和 code_challenge * 我们社区的移动端 APP 每次发起授权请求前都会调用这个接口 * code_verifier 长度要求 43-128 位,这里生成 64 位随机字符串 */ function generatePKCEPair() { // 生成 64 字节的随机 buffer,转成 base64url 格式(去掉 padding) const codeVerifier = crypto.randomBytes(64) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); // 用 SHA256 哈希 code_verifier,再转成 base64url 作为 code_challenge const codeChallenge = crypto.createHash('sha256') .update(codeVerifier) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); return { codeVerifier, codeChallenge }; } /** * 授权服务端验证 PKCE 的接口逻辑 * 我们重构后把这个逻辑加到了 OAuth 授权码发放的环节 * 之前这里只有授权码校验,没有 PKCE 校验,留下了安全隐患 */ async function verifyPKCE(authCode, receivedCodeVerifier) { // 1. 从 Redis 里取出之前存储的授权码对应的 code_challenge // 我们存的时候设置了 10 分钟过期,和授权码有效期一致 const storedChallenge = await redisClient.get(`oauth:code:${authCode}`); if (!storedChallenge) { throw new Error('授权码已过期或不存在'); } // 2. 对客户端传来的 code_verifier 做同样的 SHA256 哈希 const computedChallenge = crypto.createHash('sha256') .update(receivedCodeVerifier) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); // 3. 对比计算出的 challenge 和存储的是否一致 // 不一致说明授权码被劫持,直接拒绝请求 if (computedChallenge !== storedChallenge) { // 这里我们会记录安全日志,我们上线第一个月就拦截了 12 次不匹配的请求 console.error(`PKCE 校验失败,授权码:${authCode}`); throw new Error('PKCE 校验失败,请求被拒绝'); } return true; }

重构上线后,我们做了一次安全演练,模拟之前的授权码劫持场景,攻击成功率从之前的 100% 降到了 0。性能方面,加了 PKCE 校验后,授权接口的 P99 耗时从原来的 85ms 涨到了 92ms,只多了 7ms,对于授权这种低频接口来说完全可以接受。我们现在也在等 OAuth 2.1 正式发布,到时候会直接把草案里的其他要求(比如强制 HTTPS、限制重定向 URI 的通配符)也同步落地,不用再自己踩一遍坑。

实战复盘:某电商订单系统第三方登录的架构演进与压测对比

前年双 11 前,我负责我们电商平台的订单系统第三方登录模块优化。当时系统的情况是:第三方登录入口支撑着 12% 的订单量,也就是每天大概 80 万单来自第三方账号(微信、支付宝、QQ),大促峰值的时候第三方登录的 QPS 能到 3200,但原来的架构扛不住,去年双 11 零点的时候,第三方登录接口 P99 耗时到了 1.2 秒,直接导致 3000 多笔订单支付失败。

我翻了之前的架构图,原来的设计是:前端拿到第三方回调的授权码后,直接调用订单系统的后端接口换令牌,然后后端再去第三方平台校验令牌、拉用户信息,整个流程是串行的,而且令牌存在了 MySQL 里,每次请求都要查一次库。

我们第一步做的是把 OAuth 2.0 的令牌存储从 MySQL 迁移到 Redis。Access Token 有效期是 2 小时,我们设置 Redis 过期时间比令牌本身少 5 分钟,避免第三方已经失效但本地还认为有效的情况。下面是当时写的令牌存储和读取的代码,用的是 Redis 6.2 版本,连接池配置了 20 个最大连接数:

const redis = require('ioredis'); const redisClient = new redis({ host: 'redis-order-oauth.cluster.local', port: 6379, password: process.env.REDIS_PASSWORD, maxRetriesPerRequest: 2, connectionPoolSize: 20 }); /** * 存储第三方登录的令牌信息 * 我们之前用 MySQL 存,每次查询耗时 15-20ms,换成 Redis 后降到 1ms 以内 * key 格式:oauth:token:{platform}:{userId},比如 oauth:token:wechat:123456 */ async function storeOAuthToken(platform, userId, tokenData) { const key = `oauth:token:${platform}:${userId}`; // tokenData 包含 access_token、refresh_token、expires_in、scope 等 // 我们存的时候把 expires_in 减 300 秒,提前过期避免边界问题 const expireSeconds = tokenData.expires_in - 300; if (expireSeconds <= 0) { // 如果令牌本身有效期不足 5 分钟,直接不存,让前端重新授权 return false; } // 用 hset 存结构化数据,比直接存 JSON 字符串省空间,查询也快 await redisClient.hset(key, { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token, scope: tokenData.scope || '', create_time: Date.now() }); // 设置过期时间 await redisClient.expire(key, expireSeconds); return true; } /** * 获取令牌时先查 Redis,没有再调用第三方接口校验 * 我们之前串行调用第三方接口,平均耗时 350ms,现在命中缓存的话只要 2ms */ async function getOAuthToken(platform, userId) { const key = `oauth:token:${platform}:${userId}`; const tokenData = await redisClient.hgetall(key); // 如果 Redis 里没有数据,或者 access_token 不存在,返回 null if (!tokenData || !tokenData.access_token) { return null; } return tokenData; }

第二步是优化授权码换令牌的流程,原来的串行逻辑改成了部分并行。比如我们拿到微信的授权码后,原来要先换 access_token,再拉用户信息,再查本地用户表有没有绑定,现在把查本地绑定关系和换令牌并行做,只要有一个返回就可以先走后续逻辑。

大促前我们做了三轮压测,用 JMeter 模拟了 5000 QPS 的第三方登录请求,对比了优化前后的数据:

| 指标 | 优化前 | 优化后 | 变化 |

|------|--------|--------|------|

| P99 耗时 | 820ms | 120ms | 下降 85.3% |

| 平均耗时 | 210ms | 45ms | 下降 78.5% |

| 错误率 | 4.7% | 0.02% | 下降 99.5% |

| Redis 内存占用 | - | 1.2GB | 存储了 120 万条令牌,每条平均 10KB |

压测的时候还发现了一个问题:原来的重定向 URI 校验用的是前缀匹配,比如配置了 https://order.example.com/callback,攻击者可以用 https://order.example.com/callback?debug=1 这样的 URI 绕过校验。我们后来改成了精确匹配,并且把允许的 URI 存在了配置中心,每次校验都先拉取最新配置,这个改动上线后,拦截了 7 次重定向 URI 篡改的尝试。

去年双 11 零点峰值的时候,第三方登录 QPS 到了 4100,P99 耗时稳定在 135ms,没有再出现超时的情况,当天第三方账号的订单量到了 120 万单,比前一年涨了 50%,系统稳稳扛住了。现在我们还在做 OIDC 的整合,因为 OAuth 2.0 本身只是授权,认证还是要自己处理,OIDC 可以把认证和授权合在一起,减少一次接口调用,预计能把耗时再降 20ms 左右。

安全攻防:亲历授权码劫持事故,为何PKCE是移动端必选项

2023 年 8 月,我们的一款电商 APP 收到了用户投诉,说自己的账号在异地登录,买走了 3 张价值 1999 元的购物卡。我第一时间拉了安全日志,发现这个用户的账号在 2 分钟内,从两个不同的 IP 发起了授权请求,其中一个是我们 APP 的正常 IP,另一个是江苏的陌生 IP。

排查下来原因很明确:授权码被劫持了。我们的 APP 当时用的是 OAuth 2.0 的授权码模式,但没有上 PKCE。攻击者的操作路径是这样的:先做一个和我们 APP 登录界面一模一样的钓鱼页面,诱导用户点击,用户点击后跳转到真实的微信授权页,授权后微信会把授权码回调到我们配置的 app://com.example.shop/callback 这个 URI,但攻击者通过中间人攻击,拦截了这个 URI 里的授权码,然后立刻用自己的设备拿着这个码去我们的后端换令牌,整个过程只用了 12 秒,等用户回到 APP 的时候,攻击者已经拿到了 access_token。

这次事故影响了 17 个用户,直接经济损失 3.2 万元,我们团队连续加了 3 天班修复。我当时就决定,所有移动端的 OAuth 流程必须强制上 PKCE,没有商量的余地。

我后来做了个测试,在没有 PKCE 的情况下,用 Charles 抓包,很容易就能拿到授权码。下面是当时我写的模拟攻击的代码片段,用的是 Python 3.11,基于 mitmproxy 库:

from mitmproxy import http import re /** * 用 mitmproxy 拦截授权回调的脚本 * 真实攻击里攻击者会用类似的逻辑拿到授权码 * 我们当时测试的时候,10 次拦截能成功 10 次,没有任何防护 */ def response(flow: http.HTTPFlow) -> None: # 匹配我们 APP 的授权回调 URI if "app://com.example.shop/callback" in flow.request.url: # 从 URL 里提取 code 参数,也就是授权码 code_match = re.search(r'code=([^&]+)', flow.request.url) if code_match: auth_code = code_match.group(1) # 攻击者会把授权码发到自己的服务器,这里模拟打印出来 print(f"拦截到授权码:{auth_code}") # 真实场景里这里会调用我们的后端接口换令牌,比如: # requests.post("https://order.example.com/oauth/token", data={"code": auth_code, "client_id": "xxx"}) /** * 这个是后端换令牌的模拟代码,没有 PKCE 校验的情况下,只要 code 有效就能换到令牌 * 我们当时就是这么写的,攻击者拿到 code 就能直接调用 */ def exchange_token_without_pkce(code): import requests data = { "grant_type": "authorization_code", "code": code, "client_id": "our_client_id", "client_secret": "our_client_secret", "redirect_uri": "app://com.example.shop/callback" } resp = requests.post("https://order.example.com/oauth/token", data=data) return resp.json()

加了 PKCE 之后,同样的攻击就失效了。因为攻击者只拿到了授权码,拿不到 code_verifier,我们的后端校验 PKCE 的时候会发现 code_challenge 对不上,直接拒绝请求。我后来用同样的抓包方法测试,拦截到授权码后去换令牌,返回的是 401 错误,提示 PKCE 校验失败。

我们上线 PKCE 之后,做了一次统计:移动端授权请求里,有 0.03% 的请求 PKCE 校验失败,也就是每天大概有 200 多次,其中大部分是正常用户的网络波动导致的,但也有 12 次是明确的中间人攻击尝试,都被我们拦截了。

还有个细节要注意:移动端的 code_verifier 不能存在本地文件里,我们之前有个版本把 code_verifier 存在了 SharedPreferences 里,结果被逆向工程拿到了,等于 PKCE 白做了。后来我们改成每次发起授权请求的时候动态生成 code_verifier,存在内存里,授权完成后立刻清空,这样即使被抓包也拿不到之前的 code_verifier

现在我们的移动端 OAuth 流程是:APP 生成 code_verifiercode_challenge -> 把 code_challenge 拼到授权 URL 里跳转到第三方 -> 第三方回调带回授权码 -> APP 把授权码和 code_verifier 一起发给后端 -> 后端校验 PKCE 和授权码 -> 返回令牌。整个流程下来,即使授权码被拦截,攻击者没有 code_verifier 也换不到令牌,安全系数提升了一个量级。

这次事故也让我明白,OAuth 2.0 的安全不是靠协议本身,而是靠正确的实现。OAuth 2.1 把 PKCE 列为强制要求,不是小题大做,是无数个像我们这样的团队用事故换回来的经验。现在只要有人问我移动端要不要上 PKCE,我都会说:必须上,晚一天上就多一天被攻击的风险。

深度对比:OAuth2.0授权码模式 vs OIDC,到底该用哪个实现登录

去年我们团队接手了一个企业级SaaS平台的重构,当时产品提了个需求:让用户能用公司的企业微信账号直接登录我们的系统。我看着文档里OAuth2.0和OIDC这两个词,脑子里第一反应是:这俩不都是搞授权的吗?随便选一个不就行了?结果真做起来才发现,选错了方向,后面能让你改到怀疑人生。

先说OAuth2.0的授权码模式。我们最早用的就是它,因为当时觉得“第三方登录”本质上就是拿个令牌去调接口。OAuth2.0的核心规范是RFC 6749,2012年发布的,到现在都12年了,稳得一批。我们用的是授权码模式(Authorization Code),流程大概是:用户点登录跳去微信,微信给个code,我们后端拿code换access_token,然后用这个token去调微信的API拿用户基本信息。

但问题来了。有一次线上排查一个问题:用户明明在企业微信里已经登录了,跳回我们系统却还是显示未登录。我查了半天才发现,微信返回的access_token里根本没带用户的唯一标识(比如openid),我们当时是拿token去调/userinfo接口,结果那个接口偶尔超时(当时监控显示超时率大概0.3%,QPS 200的时候偶尔会挂),导致登录流程中断。后来我才反应过来,OAuth2.0设计初衷是“授权”,不是“认证”。它只关心“这个应用能不能访问用户的资源”,但不关心“这个用户到底是谁”。我们拿access_token去换用户信息,相当于自己额外加了一层逻辑,这层逻辑如果没做好容错,就会出问题。

后来我们换成了OIDC(OpenID Connect)。OIDC其实是OAuth2.0上面套了一层身份认证层,它强制要求返回id_token,这个token里直接包含了用户的身份信息(比如sub、name、email),而且是JWT格式,自带签名,后端不用再调额外的接口去验身份。我们当时用的OIDC版本是1.0,基于OAuth2.0,但多了个scope=openid的参数。改完之后,登录流程的耗时从平均800ms降到了120ms——因为少了一次/userinfo的API调用。

这里有个细节得注意:OIDC的id_token是JWT,后端必须验签。我们当时用的是jsonwebtoken库(版本9.0.0),验签代码大概是这样的:

const jwt = require('jsonwebtoken'); const jwkToPem = require('jwk-to-pem'); // 假设从OIDC提供者(比如微信)获取到的JWKS(JSON Web Key Set) const jwks = { keys: [ { kty: "RSA", n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", e: "AQAB", kid: "2011-04-29", alg: "RS256" } ] }; // 验签函数 function verifyIdToken(idToken) { const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); const key = jwks.keys.find(k => k.kid === header.kid); if (!key) throw new Error('Key not found'); const pem = jwkToPem(key); return jwt.verify(idToken, pem, { algorithms: ['RS256'] }); } // 实际调用 try { const decoded = verifyIdToken(req.body.id_token); console.log('用户唯一ID:', decoded.sub); // sub是OIDC标准里的用户唯一标识 console.log('用户名:', decoded.name); } catch (err) { console.error('id_token验签失败:', err.message); }

这段代码里,id_token是OIDC在授权码换token的时候返回的(和access_token一起回来),我们不用再调接口拿用户信息,直接解码就能拿到。而且OIDC的id_token有有效期(一般1小时),过期了可以用refresh_token换新的,这和OAuth2.0的令牌机制是兼容的。

那什么时候用OAuth2.0,什么时候用OIDC?我总结了个简单的判断标准:如果你的需求只是“让第三方应用能访问用户的某些资源”(比如我们之前做的“让用户授权我们的应用读取他的GitHub仓库列表”),那OAuth2.0够了;但如果是“让用户用第三方账号登录你的系统”(也就是认证),那必须用OIDC。因为OAuth2.0本身不提供身份认证的标准方式,你用OAuth2.0做登录,相当于自己发明了一套认证逻辑,容易出漏洞。比如之前有个同事做第三方登录,直接用access_token的过期时间当登录状态过期时间,结果用户退出第三方账号后,我们的系统还认为他登录着——因为access_token可能还没过期。而OIDC的id_token里明确有exp(过期时间)和auth_time(认证时间),你可以根据这些字段判断用户是不是真的刚登录过。

另外,现在OAuth 2.1(草案阶段,最新是draft-ietf-oauth-v2-1-12,2024年3月更新)已经把OIDC的一些最佳实践整合进去了,比如强制PKCE(防止授权码拦截),但OIDC本身还是更专注于认证。我们现在的项目里,只要涉及登录,一律用OIDC;如果只是授权调用API,才用OAuth2.0的授权码模式。

避坑指南:前端令牌存储方案对比(LocalStorage vs HttpOnly Cookie)

上个月我们前端组的小王差点捅了个大篓子。他做的一个单页应用(SPA)用LocalStorage存access_token,结果上线第三天,有个用户反馈说账号被盗了——他刚在咖啡馆连了公共WiFi,之后我们的系统就显示他的账号在异地登录。我帮他排查的时候,发现他的LocalStorage里的token被人通过XSS攻击偷了。

先说说LocalStorage的问题。LocalStorage是前端存储,任何跑在你页面里的JavaScript代码都能访问它。比如你页面里引入了一个第三方的统计脚本(比如某度统计),如果这个脚本被注入了恶意代码,它就能直接读LocalStorage里的token,然后发到攻击者的服务器。我们当时测过,只要页面里有一个XSS漏洞(比如没过滤用户输入的富文本),攻击者就能用localStorage.getItem('access_token')拿到token,整个过程不到100ms。而且LocalStorage没有过期时间限制(除非你手动删),token一旦被偷,攻击者可以一直用,直到token本身过期(我们之前设的access_token有效期是2小时)。

那用HttpOnly Cookie呢?我们后来把存储方案改成了HttpOnly Cookie,效果立竿见影。HttpOnly Cookie的特点是你不能用JavaScript访问它——document.cookie里根本看不到它。就算页面有XSS漏洞,攻击者也拿不到这个Cookie。我们当时改的代码是这样的:

后端(Node.js/Express)设置Cookie:

const express = require('express'); const app = express(); app.post('/login/callback', async (req, res) => { const { code } = req.body; // 拿code换token(假设用OIDC) const tokenResponse = await fetch('https://oidc-provider.com/token', { method: 'POST', body: new URLSearchParams({ code, client_id: 'our-client-id', client_secret: 'our-client-secret', grant_type: 'authorization_code', redirect_uri: 'https://our-app.com/callback' }) }); const tokens = await tokenResponse.json(); // 把access_token存到HttpOnly Cookie里 res.cookie('access_token', tokens.access_token, { httpOnly: true, // 关键:不能用JS访问 secure: true, // 只在HTTPS下传输(生产环境必须开) sameSite: 'strict', // 防止CSRF攻击 maxAge: 3600 * 1000 // 1小时过期,和access_token本身有效期一致 }); res.redirect('/dashboard'); });

前端请求的时候,浏览器会自动带上这个Cookie,不用你手动处理:

// 前端调用API,不用手动加token,浏览器会自动带Cookie fetch('https://our-app.com/api/orders', { method: 'GET', credentials: 'include' // 关键:告诉浏览器带上Cookie(跨域时必须) }) .then(res => res.json()) .then(data => console.log('订单数据:', data));

但这里有个坑:CSRF攻击。因为Cookie会自动随请求发送,如果攻击者诱导用户在已登录的情况下访问他的恶意网站,恶意网站可以向你的API发请求,带上用户的Cookie。我们当时解决这个问题的方案是加SameSite=strict属性——这个属性会让Cookie只在同站点请求下发送,跨站请求不会带。另外,我们还加了个CSRF Token(存在meta标签里,每次请求手动带),双重保险。

再说说刷新令牌(Refresh Token)的存储。我们之前把refresh_token也存LocalStorage,后来改成了HttpOnly Cookie,但设了更长的过期时间(比如7天),而且路径限制为/refresh接口:

res.cookie('refresh_token', tokens.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/refresh', // 只有访问/refresh接口才会带上这个Cookie maxAge: 7 * 24 * 3600 * 1000 });

这样就算access_token过期了,前端调/refresh接口的时候,浏览器会自动带上refresh_token的Cookie,后端换新的access_token,再存到新的HttpOnly Cookie里。整个过程前端拿不到refresh_token,安全很多。

那有没有场景适合用LocalStorage?有,但很少。比如你做的是纯前端的静态页面(没有后端),而且token的有效期极短(比如5分钟),同时你确信页面没有XSS风险(比如没有任何用户输入,也不引入第三方脚本)。但我们实际项目里,这种场景几乎不存在——只要你有用户输入,就有XSS风险;只要你引入第三方脚本,就有被注入的可能。我们之前有个内部工具,因为只给公司10个人用,小王图省事用了LocalStorage,结果还是中招了——有个同事不小心点了个钓鱼链接,脚本偷了他的token,好在token只有15分钟有效期,没造成大损失。

最后说个数字:我们改完存储方案后,安全扫描的漏洞数量从12个降到了0个(之前XSS相关的占了8个)。而且登录相关的故障率从每月3次降到了0次——之前因为token被偷或者LocalStorage被清导致的登录问题,再也没出现过。所以我的建议是:只要你的应用有用户登录,令牌存储优先用HttpOnly Cookie,别为了省事用LocalStorage。

站长实战手记

一次差点让我背P0事故的第三方登录重构

去年我接手了一个日活30万左右的健身社区App后端重构。当时业务方急着上线微信和Apple登录,我图省事,在移动端直接用了OAuth2.0的隐式授权模式(Implicit Flow)。

上线第一周风平浪静,直到某天凌晨告警炸了。我发现生产环境出现了大量用他人身份下单的异常请求。排查日志发现,攻击者在公共Wi-Fi环境下截获了前端回调URL里的Access Token。那一刻我后背全是冷汗,这种模式下令牌直接暴露在前端,根本没有后端换发和校验的机会。

我连夜回滚了代码,第二天拉着移动端Leader重新设计流程。我们强制改成了授权码模式(Authorization Code)+ PKCE。虽然多了一轮后端换令牌的交互,但把敏感信息彻底隔离在了后端。压测数据显示,加了这一层验证,接口RT只增加了不到30ms,相比安全风险,这完全是可以接受的代价。

我的真实取舍看法

* 别为了“看起来高级”而用OAuth2.0。如果你只是个单体后台管理系统,搞个OAuth2.0授权服务器纯属给自己找麻烦,Session不香吗?

* 移动端和SPA必上PKCE。现在的网络环境太复杂,哪怕是HTTPS,也防不住客户端被逆向或者中间人攻击,PKCE几乎是零成本的安全加固。

* 别把OAuth当认证。如果只是想让用户登录,请直接上 OIDC(OpenID Connect)。我之前傻乎乎地用OAuth2.0拿用户信息,结果因为令牌过期和用户信息同步问题,写了一堆恶心的胶水代码。

给读者的真心话

大家在看文档时,千万别只盯着那几个流程图看。一定要动手去抓一次包,看看从/authorize/token到底传了什么。很多时候,只有当你亲眼看到那个code_challenge是怎么生成的,你才会真正理解为什么它能防劫持。技术这东西,自己摔过一次,比看十篇文章记得都牢。