ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OAuth2.0与OIDC、JWT实战:授权码模式、Token续签及SPA安全落地

OAuth2.0与OIDC、JWT实战:授权码模式、Token续签及SPA安全落地 做了多年后端认证授权这块几乎每个项目都绕不开。后台管理系统、开放平台、小程序后端、App接口网关无论哪种形态最终都需要回答两个问题请求的人是“谁”以及这个人能“做什么”。OAuth 2.0、OIDC、JWT这三个标准组合起来恰好把这两个问题拆解得很干净OAuth 2.0负责授权流程OIDC在授权之上补充了身份认证JWT作为token载体统一了凭证格式。这篇文章我从实际项目视角把整个认证授权流程的选型逻辑、授权码模式完整流程、JWT漏洞防范、token续签设计以及SPA落地时的验证码和拦截器实现逐个拆开讲清楚。无论是刚开始接触认证授权的后端开发还是被老系统遗留token方案坑过的同学这篇文章应该都能给你一些可落地的参考。1. 组合拳怎么打OAuth 2.0、OIDC、JWT各自负责什么很多团队在做用户登录时习惯从一张用户表开始自研密码哈希、登录态、token签发、权限校验全部自己写短期内看起来没问题一旦遇到多端登录、第三方账号接入、多个内部系统互相调用这套自研方案就开始处处掣肘。问题不在于自研不行而在于没有把“身份认证”和“资源授权”这两个概念分开。1.1 自研账号体系的问题在哪里自研账号体系最典型的痛点是重复造轮子而且造得不够安全。手机验证码要自己对接短信服务商密码找回要自己设计防滥用逻辑多设备登录要自己维护会话表权限模型要从RBAC开始一点点建。更麻烦的是token的格式没有标准有的团队随手拼一个随机串存Redis有的团队把用户ID直接塞进Base64然后加个签名前端拿到token之后解析方式五花八门。这类方案最大的隐患不在正常流程而在异常场景。密钥管理松懈导致token能被伪造日志里打出完整token导致泄露退出登录只清了前端存储导致旧token还能继续用这些坑我在不同项目里都见过不止一次。后来大家越来越倾向用成熟标准来兜底就是因为标准协议把常见攻击面都提前想过了。1.2 三个标准的边界与分工OAuth 2.0解决的是授权问题。它的核心产出是Access Token描述的是“某个客户端经过用户授权后可以代表用户访问哪些资源”。注意这里OAuth 2.0本身并不关心用户是谁也不关心token里装的是什么内容它只负责定好整个授权流程怎么走谁发起、谁同意、谁发token、资源服务如何校验。它更像一个授权框架而不是一个身份协议。OIDC也就是OpenID Connect是构建在OAuth 2.0之上的身份认证层。它在授权流程里增加了一个ID Token用于告诉客户端当前登录的用户是谁以及这个身份是否经过核实。有了OIDC第三方应用才可以放心地说“用户已登录就是这个sub对应的那个人”这是OAuth 2.0本身没有的能力。JWT则是一套token格式标准。它把信息封装成三段式结构用签名保证内容没有被篡改。在OIDC流程里ID Token约定必须用JWTAccess Token也常用JWT承载作用域和用户标识。三者之间的关系可以这样理解OAuth 2.0是订好的酒店授权规则OIDC是前台核对客人身份的环节而JWT是那张写着房号和权限的房卡。1.3 方案选型对比Session方案与JWT方案的适用边界不少团队纠结选Session还是JWT这其实不是纯技术问题而是状态管理方式的问题。传统Session方案把会话状态留在服务端客户端只保存一个Session ID优点是服务端可以随时踢人、强制下线、控制设备数量缺点是分布式环境下要解决Session共享要么做粘性会话要么引入Redis统一存储。JWT方案的核心区别是把状态“下放”到客户端。服务端无状态只需要用密钥或公钥验证签名天然适合水平扩展也方便在多个服务之间共享身份信息。但代价是token无法在服务端直接撤销过期时间只能靠exp来控制一旦token泄露在到期之前都是可用的。所以我见过不少团队最终的落地方式是用户登录后用OIDC拿到的身份信息去签发自己系统的Access Tokenaccess token做短生命周期再配一个服务端可撤销的Refresh Token。这样一来无状态校验的快和可主动撤销的稳都有了这也是我建议大多数项目参考的架构。2. JWT结构解析一个Token被拆开后能看到什么做认证授权绕不开JWT而理解JWT最简单的方法是拆开一个真实token看看里面是什么。一个标准JWT长这样eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21heS5jb20iLCJzdWIiOiIxMjM0NTYiLCJleHAiOjE3MDAwMDAwMDB9.tX7jKQfYVzVZkXZQm9pW4sZqJ9vL8nXzM5nF0Yp0JvU。2.1 三部分结构Header、Payload与SignatureJWT由三个用点号分隔的部分组成。第一部分是Header声明token的类型和签名算法通常长这样{alg:RS256,typ:JWT}。第二部分是Payload存实际需要传递的声明信息比如iss是签发者、sub是用户唯一标识、aud是接收方、exp是过期时间还可以塞自定义业务字段。第三部分是Signature用Header里声明的算法把前两部分编码后的字符串加上密钥一起做签名。这里的编码方式是Base64URL它跟普通的Base64编码有区别把Base64里的加号换成减号、斜杠换成下划线并且去掉补齐用的等号。这样做是为了让token能安全地放在URL参数、Authorization头以及Cookie里不需要额外转义。签名生成的过程本质上是对前两段内容做了完整性保护。哪怕有人把Payload里的角色从“普通用户”改成“管理员”只要他没有私钥或对称密钥改动后的第三段签名就无法通过服务端校验服务端直接拒收。2.2 签名算法怎么选HS256还是RS256HS256是对称签名算法签发和校验用的是同一个密钥。它的优点是实现简单、性能好缺点是密钥一旦泄露攻击者既能签也能验而且服务端和客户端如果共享密钥客户端可以直接伪造token。所以HS256只适合单服务内部使用密钥仅存在于后端服务之间。RS256是非对称算法私钥放在授权服务器用于签发公钥放在资源服务器用于校验。这样一来签发和校验分离多个下游服务只需要持有公钥就能验证token是否由信任的授权服务器签发不需要共享任何敏感密钥。开放平台、微服务架构、第三方接入场景下RS256是唯一合理的选择。我建议新项目直接采用RS256配合授权服务器的JWKS端点动态获取公钥。这样密钥轮换也方便授权服务器更换私钥时下游服务只需要定时拉取新的公钥即可不用改配置。2.3 关键Claims与有效期设计Payload里的claim是JWT的灵魂。最核心的几个字段包括isstoken签发者的标识通常是授权服务器的URL校验时必须与预期值完全一致sub主题即用户唯一标识客户端应该用它作为用户的内部IDaudtoken接收方防止一个token在多个客户端之间串用exp过期时间戳服务端必须严格校验当前时间是否早于expiat签发时间可用于统计和审计jtitoken唯一编号可用于实现token级撤销和黑名单有效期设计上Access Token建议控制在15分钟到2小时之间。太短会导致请求频繁401太长的风险则是泄露后长时间可用。Refresh Token可以放到7天到30天按业务对安全性的敏感程度设定。我见过一些项目把Access Token设成7天理由是“用户不想频繁登录”这其实是用Refresh Token能解决的问题不该牺牲access token的安全性。3. 授权码模式实操从跳转到换Token的全流程拆解OAuth 2.0定义了多种grant type授权码模式是目前Web和移动端最佳实践里唯一被推荐的交互方式。之所以说“唯一”是因为授权码模式全程经过两个步骤先从前端跳转拿到一个一次性授权码再由后端用授权码换取token。好处是Access Token不经过浏览器和SPA的JS环境泄露面大幅缩小。3.1 四种许可类型速览为什么推荐授权码模式OAuth 2.0最初的4种许可类型里implicit模式因为在URL上直接返回access_token、有泄露和劫持风险已经被业界淘汰password模式要求客户端直接收集用户名密码和“客户端可信”的假设绑定现在主流厂商也不再支持client_credentials只适合机器之间调用没有用户参与。剩下的authorization_code模式配合PKCE扩展是当前唯一能同时覆盖Web、SPA、移动端的通用方案。授权码模式还有一个关键优势后端服务器在换取token时才能拿到Access Token和Refresh Token。因为这一步发生在服务端token没有暴露给浏览器的JS环境XSS攻击想偷token的难度会大很多。对于有自己后端的项目我建议一律走授权码模式。3.2 授权URL参数逐项拆解一个典型的授权URL长这样GET /oauth2/authorize HTTP/1.1 Host: auth.example.com response_typecode client_idweb_app redirect_urihttps://app.example.com/callback scopeopenid%20profile%20email statexyz_random_string code_challengeE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM code_challenge_methodS256每个参数都有明确的安全意图。redirect_uri必须精确匹配注册值否则攻击者可以把授权码跳到自己的服务器上。state是一个随机值用户在发起授权前生成并存在本地回调时如果state不匹配整条流程直接终止这是防CSRF的关键。code_challenge和code_challenge_method是PKCE的核心SPA或移动端没有client_secret只能用这个方式证明授权码确实是从自己发起的请求那里换来的。实际操作里我见过有人把state写死或者用固定时间戳这等于没有防御。正确做法是每次授权请求都生成一个足够随机的state并用短期Session或内存保存回调时逐一校验。3.3 换取Token与ID Token校验用户完成授权后浏览器重定向到redirect_uri带上了code和state。后端第一步校验state第二步用code加上client_id、client_secret、redirect_uri以及之前的code_verifier去换取token。POST /oauth2/token HTTP/1.1 Host: auth.example.com Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code codereplace_with_authorization_code redirect_urihttps://app.example.com/callback client_idweb_app client_secretyour_client_secret code_verifierdBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk授权服务器返回的响应里通常包含四个字段access_token、id_token、refresh_token和token_type。id_token是OIDC的核心客户端必须校验它的签名、iss、aud和exp还要校验nonce防止重放攻击。校验通过后从id_token的sub字段里取用户唯一ID再结合profile、email这些scope对应的claim就可以在本地建立用户会话。3.4 一个可复用的Node.js接入示例我这里用一个Node.js最小的接口示例演示后端如何完成授权URL构造和token换取。实际项目中可以把这些逻辑封装成中间件。import crypto from crypto; // 第一步生成并保存state和code_verifier再拼授权URL function buildAuthorizeUrl() { const state crypto.randomBytes(16).toString(base64url); const codeVerifier crypto.randomBytes(32).toString(base64url); const codeChallenge crypto .createHash(sha256) .update(codeVerifier) .digest(base64url); // 把 state 和 codeVerifier 存入短期存储比如 Redis // await redis.set(oauth:${state}, codeVerifier, { EX: 600 }); const authorizeUrl new URL(${ISSUER}/oauth2/authorize); authorizeUrl.searchParams.set(response_type, code); authorizeUrl.searchParams.set(client_id, CLIENT_ID); authorizeUrl.searchParams.set(redirect_uri, REDIRECT_URI); authorizeUrl.searchParams.set(scope, openid profile email); authorizeUrl.searchParams.set(state, state); authorizeUrl.searchParams.set(code_challenge, codeChallenge); authorizeUrl.searchParams.set(code_challenge_method, S256); return authorizeUrl.toString(); } // 第二步回调接口换token async function handleCallback(code, state) { const codeVerifier await redis.get(oauth:${state}); if (!codeVerifier) { throw new Error(state校验失败); } const params new URLSearchParams({ grant_type: authorization_code, code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: codeVerifier, }); const tokenResponse await fetch(${ISSUER}/oauth2/token, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: params, }); const tokens await tokenResponse.json(); // 校验 id_token 后再签发自己系统的 token return tokens; }这里面有个细节code_verifier不带进前端回调只用state关联起来。这样做即使授权码在URL上被捕获攻击者没有code_verifier也无法完成token换取。4. JWT漏洞总结攻击者最常用的几个切入点JWT看着简单真正部署后容易踩的坑比想象中多。这里我把常见的JWT漏洞归结为几类每类都有对应的攻击方式和防御要点这部分内容是我在实际攻防测试和代码评审中反复遇到的。4.1 最容易翻车的算法混淆攻击算法混淆攻击是JWT最经典的漏洞攻击思路是利用服务端对Header里的alg字段过于信任。比较典型的有两种第一种把alg改成none直接把Signature留空服务端如果不校验算法白名单就会把无签名的token当成合法token接受攻击者可以任意篡改Payload第二种是把alg从RS256改成HS256如果服务端拿着RS256的公钥当作HS256的对称密钥去验签攻击者由于已经知道公钥就能用这个公钥生成合法的签名。防御方式很简单校验token时先检查Header里的alg是否在允许列表内比如服务端只接受RS256就明确拒绝alg为none、HS256的token。大多数JWT库都支持指定算法比如jsonwebtoken的algorithms参数代码里必须显式传参不能依赖库的默认配置。4.2 弱密钥与暴力破解HS256如果用了短密钥比如一串英文单词或者小于32字节的随机串攻击者拿到一个真实token后可以离线跑字典很快就能爆破出密钥。爆破出密钥之后攻击者就能任意伪造token这个危害是彻底的。我在某个项目里就碰到过把HS256密钥放在前端静态资源里的情况这种属于把对称密钥暴露给了客户端基本等于把大门钥匙挂在门上。防御上要么直接用RS256彻底规避对称密钥爆破问题要么确保HS256的密钥长度至少256位且只能存在于服务端环境变量或密钥管理服务里。4.3 过期时间、敏感信息与日志泄露还有三个问题虽然不像算法混淆那么“炫技”但实际危害一点不小。第一个是exp缺失或过期时间过长。有的库解析JWT时如果不校验exp攻击者拿着一个过期的旧token稍微篡改Payload里的exp字段就能继续用。防御是在校验逻辑里强制检查exp和nbf并且Access Token生命周期控制在合理范围内。第二个是把敏感信息直接塞进Payload。很多新手误以为JWT是加密的实际上Payload只是Base64编码任何人拿到token解码就能看到你的手机号、邮箱甚至身份证号。JWT只适合放不敏感的身份标识比如sub其他用户资料应该通过用户信息接口按需获取。第三个是token被打印到日志里。一次正常的HTTP请求如果框架日志把Authorization头完整打出来那token就等于被明文记录了。我在日志系统里见过大量业务服务打印完整Authorization头这类泄露很难在第一时间发现但危害是长期的。日志里至少要把token做脱敏比如只保留前四位和后四位。4.4 服务端安全配置清单经过这些整理我一般会在项目里固定一份JWT安全清单作为代码评审的检查项。服务端要用RS256算法参数显式指定JWKS公钥来源只能是配置的信任地址始终校验iss、aud、exp、nbf和noncePayload里不存放敏感信息日志中对token脱敏密钥和私钥只放环境变量或密钥管理服务不进代码库级联系统之间禁止共享单一对称密钥。别小看这份清单走一遍能挡掉绝大多数常见攻击手法。5. Token续签方案设计Access Token过期之后怎么办Access Token和Refresh Token双token机制是目前解决“安全性和体验”这对矛盾的主流方案。核心思路是Access Token设置短过期时间Refresh Token用来换取新的Access Token避免用户频繁重新登录。这个机制看似简单落地时仍然有几个关键点容易出问题。5.1 为什么不能把Access Token设得很长有人会问直接把Access Token设成7天不就没有续签这回事了吗。这个想法最大的问题在于无状态JWT一旦签发在过期前服务端无法主动让它失效。用户修改密码、账号被封禁、设备被顶号都要求凭证立刻失效但Access Token的exp还在远处资源服务依然接受它。把过期时间缩短配合刷新机制相当于把“撤销风险”的窗口缩小到几分钟级别即使泄露攻击者能利用的时间也大为缩短。所以推荐策略是Access Token设15分钟到2小时Refresh Token设7天到30天。如果业务对安全要求比较严格比如涉及支付管理Access Token可以压到15分钟Refresh Token设7天内部管理系统可以把Access Token设到2小时Refresh Token设14天。5.2 Refresh Token的正确打开方式Refresh Token本身尽量不要用JWT而是用随机不透明字符串并把它存储在服务端比如Redis里。这样一来服务端拥有了主动撤销能力用户改密码、被顶号、安全风控拦截时直接删除Redis里的refresh_token即可下线。配套的有效期用Redis的TTL控制不需要在token里费劲维护exp。换取新token的接口设计上有一个容易被忽略的安全习惯旧的Refresh Token一旦成功换取了新token必须立刻作废并返回一个新的Refresh Token这叫Refresh Token轮换。这样即使旧token被攻击者截获也只能用一次攻击者下一次拿到的是一个已经被服务端标记失效的token。轮换机制会让重放攻击变得非常困难。5.3 滑动续签与Refresh Token轮换滑动续签是另一个常见的策略指的是用户只要活跃Refresh Token就不会过期。每次刷新token时按固定时间窗口顺延有效期例如本来30天过期用户在第20天刷新了一次新的refresh token就再给30天。这个策略让长时间不活跃的用户必须重新登录而活跃用户几乎感受不到会话过期。适合电商、办公类产品能平衡安全性和体验。不过滑动续签会和轮换机制产生冲突如果每次都返回新refresh token并作废旧token那旧token到底算不算“过期”。实际落地时要把这两个机制一起设计。我的建议是刷新接口同时做轮换和顺延新token和新refresh token一起返回旧refresh token删除。客户端只需要负责在本地替换掉旧值就行。5.4 续签场景下的并发与撤销问题有前端经验的读者应该已经想到了SPA里的并发请求会导致多个接口同时收到401然后同时触发刷新逻辑在这个瞬间可能产生多个并发的刷新请求。第一个请求成功后旧refresh token已经被轮换作废后续几个并发请求拿着相同旧token再刷新就会失败。前端的解法是用一个刷新状态锁和等待队列。第一个401触发刷新时后续401不再触发新刷新而是排队等待第一个刷新完成然后共享新token重放请求。后端的解法是把刷新接口做成幂等或加锁同一个refresh token在同一时刻最多只能成功一次。两端的防护最好都做因为客户端网络环境不可控总会有漏网之鱼直接打到后端。另一个容易出现的问题是多设备登录。用户开了手机App、浏览器网页、桌面客户端三个会话对应的refresh token是三个不同的随机串。踢掉某一台设备时只能删除对应的那个refresh token而不是把用户ID下所有会话全部清掉。所以Redis的key设计上要注意不能只存“userId - refreshToken”而应该存“userId:deviceId - refreshToken”这样才具备按设备撤销的能力。6. SPA项目落地验证码、存储与拦截器全套方案最后把视角切到前端。SPA项目的认证授权落地不只是把token存在localStorage里那么简单存储位置、验证码、HTTP拦截器、路由守卫这些环节每一项都有讲究。6.1 Token存储位置怎么选SPA下token存哪是一个讨论很多的问题。localStorage最简单但XSS攻击可以把它直接读走内存变量相对安全刷新页面就丢体验太差httpOnly Cookie安全属性最好但需要后端额外协作跨域场景还要处理CORS和CSRF。我当前比较推荐的做法是分情况处理。access token放内存让JS环境通过内存变量访问页面刷新后虽然会丢失但可以用refresh token恢复refresh token放httpOnly Cookie里HTTP Only属性让JS读不到能有效防止XSS窃取同时配合SameSiteLax或Strict来缓解CSRF风险。如果项目确实没有条件用httpOnly Cookie那至少不要把access token和refresh token一起放在localStorage里这是底线。6.2 图形验证码的实现方案登录页的验证码主要为了防止自动化脚本暴力破解和撞库。这里直接给出一个基于Node.js后端和Redis的实现思路。后端生成验证码接口返回captchaId和SVG图片内容验证码答案只存在Redis里不返回给前端。验证码绑定captchaId有效期5分钟取过一次后必须删除防止同一个验证码被反复尝试。import svgCaptcha from svg-captcha; import { randomUUID } from crypto; router.get(/auth/captcha, async (req, res) { const captcha svgCaptcha.create({ size: 4, noise: 3, color: true }); const captchaId randomUUID(); await redis.set(captcha:${captchaId}, captcha.text, { EX: 300 }); res.json({ captchaId, svg: captcha.data }); });登录时后端先校验验证码验证码错误直接拒绝成功则立刻删除验证码再走密码校验和OIDC登录流程。这里有一个细节值得注意失败的验证码校验也必须删除验证码否则攻击者可以反复用同一个captchaId撞验证码直到通过。连续失败还需要加接口级限流比如同一IP每分钟最多5次登录请求或者同一用户名失败5次后临时锁定这个能让自动化脚本的效率大打折扣。6.3 Axios拦截器与路由守卫前端处理401和自动续签的逻辑我习惯放在axios拦截器里。核心思路是请求发出前统一带上Authorization头响应收到401时判断是否已经有刷新流程在跑如果在跑就排队没在跑就触发刷新刷新成功后重放原请求刷新失败则清空本地状态并跳转登录页。一个简化版的示例let isRefreshing false; let pendingTasks []; async function refreshNewToken() { return axios.post(/auth/refresh, {}, { withCredentials: true }); } service.interceptors.response.use( (response) response, async (error) { const { response, config } error; if (response.status 401 !config._retry) { if (isRefreshing) { return new Promise((resolve) { pendingTasks.push((token) { config.headers.Authorization Bearer ${token}; resolve(service(config)); }); }); } config._retry true; isRefreshing true; try { const { data } await refreshNewToken(); const newToken data.access_token; pendingTasks.forEach((cb) cb(newToken)); pendingTasks []; config.headers.Authorization Bearer ${newToken}; return service(config); } catch (refreshError) { pendingTasks []; // 跳转登录页清理本地状态 window.location.href /login; return Promise.reject(refreshError); } finally { isRefreshing false; } } return Promise.reject(error); } );这段代码能解决绝大多数并发401刷新问题。路由守卫则负责在前端路由层面拦截未登录用户检查内存或状态管理器里有没有用户身份信息没有就跳转登录页。注意这里不要只检查有没有token因为过期token也存在更好的做法是结合token的过期时间判断如果已过期且刷新失败路由守卫应该把用户送回登录页。6.4 登出与清理细节登出操作会暴露很多项目对认证授权理解的深浅。最简单也最不完整的做法是前端清掉本地token这样用户确实回不到页面了但只要旧token没到过期时间资源服务仍然接受它这是典型的安全隐患。完整的登出应该分为四步前端调用后端登出接口后端删除当前设备对应的refresh token并且如果有服务端维护的会话状态也一并清理后端响应成功后前端再清掉本地access token如果需要单点登出再考虑调用OIDC的end_session_endpoint把所有使用同一身份的应用会话都退出。访问Access Token因为没有状态本身做不到服务端撤销我们依赖短过期时间把这个风险窗口压小再由refresh token撤销来兜底。6.5 一个完整的登录到请求链路把这些环节串起来一个典型的SPA登录流程应该是这样的用户打开登录页输入账号密码和验证码前端先请求验证码接口拿到captchaId并展示SVG提交登录请求时带上验证码和用户凭证后端校验验证码通过后走OIDC授权码流程向认证服务器换取id_token和refresh_token后端从id_token取出用户身份签发自己的access token和refresh token返回给前端前端把access token存在内存、refresh token放在httpOnly Cookie后续每一次API请求由拦截器自动带上Authorization头一旦收到401触发刷新逻辑并获得新的access token后重放请求用户点击退出调用后端登出接口后端删除refresh token前端清理内存和本地状态。回看整个认证授权流程最核心的体会是标准协议本身不会让你直接变得安全它只是把复杂问题拆成了清晰的分工。OAuth 2.0管授权过程OIDC管身份认证JWT管凭证格式每一层都有固定的校验点。把这些校验点老老实实做全再把token续签和撤销机制设计好认证授权体系基本就稳了。最后分享一个我个人的习惯每次上线认证相关功能我都会花半天时间用攻击者视角自己过一遍拿着官方文档里的JWT漏洞清单逐项测试这套流程能提前挡掉好多次线上事故。
返回列表