ARTICLE DETAIL

资讯详情

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

requests-oauthlib实战:从授权码到token自动刷新的完整指南

requests-oauthlib实战:从授权码到token自动刷新的完整指南 从接到一个“用密码账号调第三方接口”的需求开始你大概率会遇到两种情况要么对方平台压根不给你密码只给一个client_id让你走 OAuth要么你硬着头皮拿密码去换 token被网关拦下来报 401。这篇文章就聊requests-oauthlib这个库——Python 生态里处理 OAuth1 和 OAuth2 最省心的工具。它不是一个单独的认证服务器而是把 OAuth 流程中“拼授权链接、换 token、带 token 请求、自动刷新”这些脏活封装成Session的扩展直接继承 requests 的接口你之前怎么写requests.get()现在就怎么写session.get()。适合正在对接闲鱼开放平台、百度开放平台这类第三方 API或者写爬虫时要过授权闸门的同学参考。1. 先搞懂 OAuth 到底在解决什么问题1.1 为什么不能直接拿用户名密码去调接口很多人第一次接触 OAuth 时会有一个本能疑问我调你平台的 API直接传账号密码不就行了搞什么授权码、token、refresh_token这个问题在技术群里几乎每周都有人问。答案要从“委托授权”四个字说起。你写了一个爬虫脚本要抓取用户在某平台上的订单数据。这个数据属于用户本人但你作为第三方开发者不应该也不应该能拿到用户的明文密码。一旦你的脚本里出现了用户的账号密码意味着三件事同时发生密码在网络上传输、密码在你的服务器上落地、密码在你手里被存储。任何一个环节泄漏用户损失的是整个账号不只是你调用的那部分数据。OAuth 的设计目标就是用户不把密码给你而是由平台颁发一个“限定范围、限定有效期”的令牌你拿着这个令牌去访问资源。相当于你进商场不需要把身份证押给商铺而是出示商场发的临时通行证通行证只能去特定楼层过了时间自动作废。1.2 令牌和密码的本质区别令牌token和密码最大的不同在于它是“可撤销、可限定、可轮换”的。维度密码访问令牌有效期理论上永久短则几分钟长则几小时权限范围全部权限由 scope 限定泄漏后果账号完全失控到期失效可单独吊销轮换方式必须改密码通过 refresh_token 刷新做了 OAuth 对接你就会发现平台方特别强调scope参数。比如闲鱼开放平台的订单接口要求scopeorder_read你就算拿到了 tokenscope 里没有这个权限请求照样被拒。这不只是安全考量也是平台控制 API 调用边界的手段。1.3 两种主流流程授权码模式和客户端凭证模式OAuth2 里最常见的两种流程我简单归纳一下。授权码模式Authorization Code Grant适合“有用户参与”的场景。比如用户在你网站上点击“用微信登录”你跳转到微信授权页用户确认后微信回调你的服务器附带一个授权码你再用授权码去换 token。整个过程用户是明确知情并授权的。客户端凭证模式Client Credentials Grant适合“纯后端服务”的场景。比如你的服务端脚本要拉取商品列表这个数据不涉及某个具体用户而是属于应用本身。你直接用client_id和client_secret换 token不需要用户交互。这两种流程在requests-oauthlib里都有对应封装下面会逐个展开。2. requests-oauthlib 的安装和核心对象拆解2.1 安装依赖时的版本坑安装本身很简单pip install requests-oauthlib就完事。但我实际踩过几个版本相关的坑必须提醒一下。这个库依赖requests和oauthlib两个底层库。requests-oauthlib不会帮你自动升级oauthlib如果你的环境里已经有旧版oauthlib很可能出现一些诡异问题比如OAuth2Session.fetch_token()报AttributeError: OAuth2Token object has no attribute get或者刷新 token 时签名不匹配。建议安装后主动验证一下版本组合pip install requests-oauthlib python -c import requests_oauthlib; import oauthlib; import requests; print(requests_oauthlib.__version__, oauthlib.__version__, requests.__version__)我当前环境里的组合是requests-oauthlib 2.0.0、oauthlib 3.2.2、requests 2.31.0跑下来没有兼容性问题。如果你的oauthlib低于 3.0建议顺手升一下级。2.2 OAuth1Session 和 OAuth2Session 是两条不同路线很多教程把这两个类混在一起讲实际上它们是两套东西对应两种完全不同的认证协议。OAuth1Session对应 OAuth1.0a这个协议现在用得越来越少了但部分老平台还在用。它的特点是请求必须带签名签名由consumer_key、consumer_secret、token、token_secret组合计算每个请求都需要带oauth_signature。这个库帮你做了签名计算你只需要在构造函数里把这些密钥传进去from requests_oauthlib import OAuth1Session session OAuth1Session( client_keyconsumer_key, client_secretconsumer_secret, resource_owner_keytoken, resource_owner_secrettoken_secret ) response session.get(https://api.example.com/resource)OAuth2Session则完全不同。OAuth2 的 token 不参与请求体签名而是放在Authorization: Bearer token头里。OAuth2Session的核心工作是用fetch_token()换取 token然后自动在每次请求里带上这个头。我日常对接新平台基本只用OAuth2Session。老平台如果强制 OAuth1再单独封装一层。2.3 它仍然是 requests.Session这一点对写惯了 requests 的开发者特别友好。OAuth2Session继承了requests.Session所以get()、post()、put()、delete()、headers、cookies、proxies、timeout这些你熟悉的东西全部照常可用。这也意味着你可以在同一个 session 实例上设置连接池参数、挂代理、加自定义 header。比如有的平台要求额外的User-Agent你直接session.headers.update({User-Agent: my-app/1.0})就能让它自动附加到后续每个请求上不用每次手动传。3. OAuth2 授权码模式实操从授权链接到数据请求3.1 前置准备注册应用拿到 client_id对接开放平台第一步永远是去平台控制台注册应用。这一步没什么技术含量但有一个细节经常被忽略回调地址redirect_uri的填写。以百度开放平台为例注册应用后你会拿到client_id即 API Key和client_secret即 Secret Key。创建应用时必须填回调地址这个地址一定要是你实际能访问的 HTTPS 地址。很多人在这一步填了http://localhost后面测试时就各种踩坑因为平台强制要求 HTTPS 回调。我习惯的做法是本地开发时在 hosts 里把localhost映射到一个自定义域名再用反向代理把请求转发到本地服务。这样既能满足平台的 HTTPS 要求又不影响本地调试。3.2 拼授权 URLstate 参数不能省拿到client_id后第一步是引导用户访问授权页面。OAuth2Session提供了authorization_url()方法from requests_oauthlib import OAuth2Session client_id 你的client_id redirect_uri https://yourdomain.com/callback oauth OAuth2Session( client_idclient_id, redirect_uriredirect_uri, scope[basic, order_read] ) authorization_url, state oauth.authorization_url( https://openapi.baidu.com/oauth/2.0/authorize ) print(authorization_url) print(state)注意这个方法返回两个值授权链接和 state。state是 CSRF 防护参数平台会在回调时原样带回来你要做的是在生成授权链接时存好这份 state回调时再比对比对。很多教程为了省事忽略 state我强烈建议不要。攻击者可以构造伪造的授权回调诱导你接受一个不属于本会话的授权码带上 state 校验能直接挡住这种攻击。authorization_url()拼出来的链接长这样https://openapi.baidu.com/oauth/2.0/authorize?client_idxxxredirect_urixxxscopebasicorder_readstate随机字符串response_typecode核心参数response_typecode表示走授权码模式平台识别到这个参数后就会把用户引导到登录确认页而不是直接弹窗要密码。3.3 回调处理用授权码换 token用户在你的授权页点“同意”后平台会重定向到你的回调地址UR L 上带code和state两个参数。你在回调接口里要做两件事校验 state然后用 code 换 token。校验 state 的逻辑很简单from flask import request received_state request.args.get(state) if received_state ! state: # state是之前存下来的 abort(403)这一步看似多余实际上是 OAuth2 安全模型里的关键环节。忽略它等于把授权流程的完整性交给了运气。接下来用fetch_token()换 tokenfrom requests_oauthlib import OAuth2Session client_id 你的client_id client_secret 你的client_secret redirect_uri https://yourdomain.com/callback oauth OAuth2Session(client_id, redirect_uriredirect_uri) token oauth.fetch_token( https://openapi.baidu.com/oauth/2.0/token, coderequest.args.get(code), client_secretclient_secret ) print(token)fetch_token()内部发送的是 POST 请求把grant_typeauthorization_code、code、redirect_uri、client_id、client_secret都组装好然后从响应里解析出 access_token、refresh_token、expires_in。返回的 token 是一个类字典对象你可以直接取出字段也可以把它序列化存到数据库。这一步的关键参数是client_secret。注意fetch_token()并不会读取你在OAuth2Session构造函数里传的client_id来自动补全client_secret必须显式传进去。我见过不少人在这一步报错日志里显示token接口返回invalid_client原因就是把client_secret漏了。3.4 带 token 请求资源token 换到手之后接下来的请求就简单了。刚才那个oauthsession 已经把 token 绑定到内部你只需要response oauth.get(https://openapi.baidu.com/rest/2.0/something) print(response.json())OAuth2Session会自动在请求头里加Authorization: Bearer access_token。你不需要手动去拼 header也不需要担心 token 放错位置。如果你想跳过授权页直接手动给 session 塞一个已知 token也是可以的from requests_oauthlib import OAuth2Session oauth OAuth2Session(client_id) oauth.token { access_token: 已知的token, token_type: Bearer, expires_in: 3600 } response oauth.get(https://api.example.com/resource)这个技巧在调试时特别好用。比如 token 是同事从别处拿到的你想直接验证接口不用重新走一遍授权流程。4. token 过期自动刷新工程化处理的关键一环4.1 过期机制不能靠猜OAuth2 的 access_token 有过期时间通常在 1 到 2 小时之间。问题是你没法精确知道它什么时候过期因为网络延迟、服务器时钟偏差都会带来误差。很多新手会这样做请求返回 401 了就去刷新 token。这种“出错再刷新”的策略在低并发场景勉强能用但在生产环境很容易遇到尴尬情况——一个请求已经因为 401 挂了你刷新 token 后重试下一个请求又因为同样的原因挂了导致重试风暴。requests-oauthlib提供了一种更优雅的处理方式在请求发出前检查 token 的过期时间提前刷新。这个机制依赖 token 里的expires_at字段这是fetch_token()自动计算的等于“当前时间 expires_in”。如果你需要更保守的策略可以在拿到 token 后手动调整expires_atimport time from datetime import datetime, timedelta token oauth.token token[expires_at] (datetime.now() timedelta(minutes50)).timestamp() oauth.token token这样 token 相当于提前了 10 分钟被视为过期牺牲一点点 token 有效期换来了更稳定的请求成功率。4.2 用 token_updater 实现自动刷新fetch_token()支持一个token_updater参数作用是“每次刷新 token 后把新 token 存到你的存储里”。我用它配合文件、数据库或者 Redis 来做持久化import json import os def save_token(token): with open(token.json, w) as f: json.dump(token, f) oauth OAuth2Session( client_id, tokenload_token(), token_updatersave_token )关键在于每次访问时如果发现 token 快过期了OAuth2Session会自动调用刷新逻辑然后把新 token 传给token_updater。你不需要在业务代码里写任何“if token 过期”的判断。实际测试下来这个机制在单线程轮询场景下非常稳定。我曾经用它在连续跑 8 小时的任务里全程只手动刷新了一次 token其余全是自动刷新完成的。4.3 并发刷新竞争容易被忽略的雷token_updater在单线程下很香但如果你在多线程或者多进程里共用同一个 session就会遇到“并发刷新竞争”问题。场景是这样的两个线程几乎同时发现 token 过期了同时调用刷新接口。平台那边刷新 token 的逻辑是旧 refresh_token 一旦被使用就作废。于是线程 A 的刷新成功了线程 B 的刷新会因为 refresh_token 已被使用而失败——而且最麻烦的是B 失败后它手里拿的还是旧的 refresh_token后续所有请求都会 401。解决思路有两个第一用锁保护刷新逻辑让同一时刻只有一个线程能执行刷新。threading.Lock()就能搞定import threading refresh_lock threading.Lock() def safe_refresh_token(token): with refresh_lock: return oauth.refresh_token(https://api.example.com/token)第二如果业务是多进程部署靠进程内锁解决不了问题需要把“刷新动作”收敛到一个独立的服务里其他进程通过这个服务拿 token。简单来说就是 token 只存一处刷新也只在一处发生。我在实际项目里用过一个比较粗暴但有效的方案Redis 里加一个分布式锁抢到锁的请求负责刷新 token其他请求等刷新完成后再拿新的 token。这个方案能有效避免多实例同时踩到 refresh 作废气。5. 我在实战中踩过的几个坑5.1 回调地址必须和注册时一字不差这个坑我在微信开放平台、百度开放平台、闲鱼开放平台都遇到过。平台侧校验回调地址是严格字符串匹配任何差异都会导致授权失败。最常见的问题包括注册时填的https://yourdomain.com/callback代码里配置的却是https://yourdomain.com/callback/末尾多了个斜杠或者注册时用了http代码里用了https。平台返回的错误五花八门有的直接提示redirect_uri mismatch有的给你一个笼统的授权失败。我的经验是把这个地址单独抽成一个配置项一处修改全局生效不要散落在代码各处并且注册后立刻在浏览手里手动访问一次授权链接确认能正常跳转到你预期的回调地址。5.2 scope 设置不当导致接口陆续失联scope 这个参数非常坑因为不同平台对 scope 的命名规则差异巨大。比如某些平台要求scopeorder_read另一些平台则要求scoperead:order。你以为自己按照文档写对了结果 token 拿到手调用某个接口时才发现权限不够返回insufficient scope。更隐蔽的情况是你拿到的 token 里有多个 scope但requests-oauthlib在发起授权时会把 scope 参数拼在授权 URL 里如果某个 scope 在平台侧根本不存在平台要么直接报错要么静默忽略但你后续调接口时又会莫名其妙缺少权限。我的建议是在授权 URL 生成后手动检查一下拼出来的scope参数确认和你预期一致。拿到 token 后也可以打印token[scope]看看实际授予了什么权限和申请的对不上就要去找平台文档比对。5.3 不检查响应体只看状态码很多人把response.status_code 200当作请求成功的唯一标准。但在 OAuth 流程里这个判断经常失效。有的平台返回 200但响应体里某个字段告诉你“token 无效”有的平台返回 400但响应体里带着具体的错误码和错误描述。只看状态码很容易把真正的问题掩盖掉。我养成的一个习惯是每次调接口后先打印或者落日志response.text看一眼再决定下一步。尤其在做 OAuth 调试阶段响应体往往是最直接的线索。比如下面这段代码response oauth.get(https://api.example.com/resource) print(response.status_code, response.text)response.text里有error: invalid_token比你说百句“请求失败了”都管用。5.4 requests-oauthlib 不直接支持 PKCE 时的处理有些平台尤其是移动端和单页应用场景强制要求 PKCEProof Key for Code Exchange也就是在授权请求里带code_challenge和code_challenge_method参数。requests-oauthlib的authorization_url()本身不提供现成的 PKCE 参数组装你需要在生成授权链接前手动构造code_verifier和code_challenge再把它们作为authorization_url()的额外参数拼进去。import base64 import hashlib import os code_verifier base64.urlsafe_b64encode(os.urandom(32)).rstrip(b).decode() code_challenge base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest() ).rstrip(b).decode() oauth OAuth2Session(client_id, redirect_uriredirect_uri) authorization_url, state oauth.authorization_url( https://api.example.com/authorize, code_challengecode_challenge, code_challenge_methodS256 )后续fetch_token()时别忘了把code_verifier传进去token oauth.fetch_token( https://api.example.com/token, codecode, client_secretclient_secret, code_verifiercode_verifier )这个坑很隐蔽因为浏览器端的 JSSDK 通常帮你处理了 PKCE但后端脚本自己写 OAuth 时没人替你代劳。6. 选型判断什么时候用 requests-oauthlib什么时候换别的6.1 手写 OAuth vs requests-oauthlib之前为了深入理解 OAuth我尝试过手写完整的授权码流程。结论是能跑通不代表值得在生产环境里用。手写流程意味着你要自己处理授权 URL 的参数拼接和编码token 请求的请求体组装响应解析和异常处理token 刷新逻辑和并发保护不同平台的协议差异这些工作在requests-oauthlib里基本都替你做了而且它的实现经过大量用户验证边界情况处理比我手写的版本要完整得多。四个小时后能让授权流程稳定跑起来还有什么理由自己手造轮子。6.2 和其他库的对比库定位优势劣势requests-oauthlib轻量、基于 requests上手快和 requests 生态无缝衔接OIDC 支持弱一些Authlib全面的认证协议库支持 OAuth、OIDC、JWT 等学习曲线略陡google-auth谷歌专属对 Google API 优化只适合谷歌系如果你的目标是快速对接国内开放平台requests-oauthlib是性价比最高的选择。如果后面要接企业微信、OIDC 登录这类更复杂的身份认证协议再考虑迁移到Authlib。6.3 我的推荐在我的日常工作流里requests-oauthlib通常配合一个封装类使用一个函数负责创建并记住 session一个函数负责从配置或环境变量里读取 client_id 和 redirect_uri一个函数负责把 token 序列化到文件或 Redis。这套结构已经支撑我连续两年维护多个平台的数据同步脚本中间只需要偶尔根据平台 API 变动调整一次请求路径。如果你也正在写对接开放平台的代码建议先跑通授权码模式拿一份真实 token再考虑把 token 持久化、自动刷新这些高级特性加上。前 30 分钟解决“能不能调通”后面的时间再用来把“稳不稳、安全不安全”做到位。
返回列表