OAuth签名版本混淆:HMAC-SHA1与PLAINTEXT算法详解及Python实现

📅 发布时间:2026/10/19 0:10:43
OAuth签名版本混淆:HMAC-SHA1与PLAINTEXT算法详解及Python实现
很多同学一看到OAuth 签名这几个字下意识就默认这是 OAuth 2.0 的机制实际上这里藏着一个非常经典的版本混淆。HMAC-SHA1 和 PLAINTEXT 这两种签名算法出自 OAuth 1.0a 规范RFC 5849OAuth 2.0 里压根没有签名方法这个概念。标题里会出现OAuth2 HMAC-SHA1这种混搭多半是因为不少老系统的对接文档写着 OAuth 2.0实际跑的却是 1.0a 的签名流程。这篇文章就专门把这件事彻底理清楚两种签名算法各自的构造原理、Python 手写实现、什么时候该用哪个以及我实际调接口时踩过的一堆坑。这篇文章适合三类人接老平台 OpenAPI 时被签名验证折磨的对接工程师想手写 OAuth 1.0a 客户端而不是直接套库的底层开发者以及面试前想搞明白签名基字符串到底怎么回事的同学。不需要你有密码学基础只要写过 Python、见过 HTTP 请求就能跟着走完。1. 先理清版本HMAC-SHA1 和 PLAINTEXT 到底属于哪个 OAuth1.1 OAuth 1.0a 和 OAuth 2.0 不是升级关系很多人以为 OAuth 2.0 是 OAuth 1.0 的升级版就像 Python 2 升 Python 3 那样实际上这俩是两套完全不同的协议设计。OAuth 2.0 的主打思路是授权码换令牌令牌直接带走令牌本身以 Bearer Token 的形式放到请求头里服务端看到令牌就认账。换句话说OAuth 2.0 的安全重心不在怎么构造请求签名而在于令牌的颁发、过期、刷新和传输层保护。而 OAuth 1.0a 的设计思路完全不同。它的核心是每一个请求都要被签名即使你持有访问令牌发出的请求也要带上 oauth_consumer_key、oauth_token、oauth_timestamp、oauth_nonce 等一系列协议参数并且通过签名算法把这些参数和请求内容绑定在一起。服务端验签通过才认为这个请求确实是某个客户端的合法请求。所以 HMAC-SHA1 和 PLAINTEXT 这两个签名方法从诞生那天起就属于 OAuth 1.0a。你接的如果是老平台的 OpenAPI文档里出现 signature_method 这种字段不用怀疑它走的就是 1.0a 的流程。标题里的 OAuth2 只是沿用了大家的习惯性叫法或者说文档写错了版本号这种情况在真实接口对接里太常见了。1.2 为什么老机制依然值得研究你可能想问都 2025 年了为什么还要翻这种老古董原因很现实。大量企业级开放平台、支付网关、数据服务商的存量接口至今还在用 OAuth 1.0a 的 HMAC-SHA1 签名。这些接口要么是历史包袱太重没法升级要么是平台方基于安全考虑刻意保留这套无状态验签机制——因为 HMAC-SHA1 验签不需要在服务端存储会话状态天然适合分布式网关做水平扩展。另外搞懂 OAuth 1.0a 的签名基字符串Signature Base String构造过程对你理解任何带签名的 API 协议都有帮助。现在的开放平台签名方案层出不穷什么 MD5 签名、HMAC-SHA256 签名、国密 SM3 签名本质上都是同一套思路把所有请求要素归一化成一个字符串再用约定的密钥和哈希算法算指纹。你只要把 OAuth 1.0a 这套吃透了其他签名方案基本都是换汤不换药。2. 签名机制的骨架签名基字符串与编码规则2.1 签名基字符串的三段式结构HMAC-SHA1 和 PLAINTEXT 虽然一个要算哈希、一个纯拼接但它们都绕不开一个共同的前置步骤构造签名基字符串。RFC 5849 里把它的结构定义得非常死板死板其实是好事——只有两边都严格按同一套规则来才能算出一样的签名。签名基字符串由三段组成中间用连接HTTP请求方法(大写) 经过编码的基础URI 经过编码的参数字符串第一段是请求方法的大写形式比如 GET、POST、PUT、DELETE。第二段是基础 URI注意不是完整的请求 URL而是协议 主机 端口非默认端口才保留 路径查询参数和 Fragment 统统不能带进去。第三段是最容易出错的参数字符串它要把协议参数除了 oauth_signature 本身和业务请求参数全部收进来每个参数名和参数值各自做百分号编码然后按参数名排序同名参数按值排序最后用拼接。有个细节很多新手会忽略拼好的参数字符串在塞进签名基字符串之前还要整体再做一次百分号编码。也就是说会变成%26会变成%3D。这个二次编码是签名对不上的头号原因后面我会专门展开讲。2.2 RFC 3986 百分号编码才是真正的门槛两个客户端算出来的签名对不上十次有八次是编码规则不一致。OAuth 1.0a 要求的是 RFC 3986 百分号编码网上很多资料把它和 URL 编码application/x-www-form-urlencoded混为一谈这是个大坑。RFC 3986 的规则是字母、数字以及-、.、_、~这四个字符保留原样其他所有字符都要转成%XX的形式。特别注意两点空格必须编码成%20不是。用 Python 的urllib.parse.quote_plus会把空格变成用在 OAuth 签名里必死。没有安全路径字符这种说法连/都要编码成%2F。但如果用 Pythonquote函数不指定 safe 参数默认 safe/也会出错。正确写法是from urllib.parse import quote def rfc3986_encode(value): RFC 3986 百分号编码保留 -._~ 四个字符 return quote(str(value), safe-._~)我见过不少项目里手撸签名结果在编码这步就翻车。比如一个参数值是hello world正确编码是hello%20world如果用 quote_plus 就成了helloworld服务端按 RFC 3986 解析后验签直接就 401。这种事没有任何提示只能靠日志里比对签名一步一步排查。3. HMAC-SHA1 签名方法全解析3.1 从原理到公式双密钥与 HMAC 结构HMAC-SHA1 的全称是 Hash-based Message Authentication Code using SHA-1它是一种带密钥的哈希。普通哈希像 MD5、SHA-1任何人拿到原文都能验证摘要而 HMAC 需要一把密钥只有持有密钥的人才算得出正确的摘要所以它能同时保证数据完整性和一定的身份真实性。OAuth 1.0a 里 HMAC-SHA1 的输入有三样东西签名基字符串上一节说的三段式结构作为消息输入。签名密钥由客户端密钥consumer_secret和令牌密钥token_secret拼接而成中间用分隔两部分都要先做 RFC 3986 编码。哈希算法SHA-1输出 160 位20 字节的摘要最后做 Base64 编码得到最终的签名值。用公式表达就是签名密钥 rfc3986_encode(client_secret) rfc3986_encode(token_secret) 签名值 Base64( HMAC-SHA1(签名密钥, 签名基字符串) )这里有个容易想岔的点很多人以为 PLAINTEXT 才是把密钥拼起来HMAC-SHA1 的密钥怎么也是拼起来的是的OAuth 1.0a 的双密钥结构就是 consumer_secret 和 token_secret 拼在一起。即使 token_secret 为空比如还没拿到访问令牌的 request token 阶段那个也不能省密钥就是consumer_secret。3.2 Python 手写实现一步步拆解我用纯标准库实现一份不依赖任何第三方组件这样你能看到每一个环节在做什么。完整代码如下import base64 import hashlib import hmac import time import random import string from urllib.parse import quote def rfc3986_encode(value): RFC 3986 百分号编码 return quote(str(value), safe-._~) def build_signature_base_string(method, url, query_params, oauth_params): 构造签名基字符串 method: HTTP 方法大写 url: 不含查询参数的基础 URL query_params: 业务请求参数来自 query string 或表单体 oauth_params: OAuth 协议参数注意不要包含 oauth_signature params {} params.update(oauth_params) params.update(query_params) # 保险起见排除签名本身 params.pop(oauth_signature, None) # 每个键值对先各自编码再按编码后的名字排序名字相同按值排序 encoded_pairs sorted( (rfc3986_encode(k), rfc3986_encode(v)) for k, v in params.items() ) param_string .join(f{k}{v} for k, v in encoded_pairs) # 三段式拼接注意 URL 和整个参数字符串都要编码 base_string .join([ method.upper(), rfc3986_encode(url), rfc3986_encode(param_string) ]) return base_string def hmac_sha1_sign(method, url, query_params, oauth_params, client_secret, token_secret): HMAC-SHA1 签名 base_string build_signature_base_string( method, url, query_params, oauth_params ) key rfc3986_encode(client_secret) rfc3986_encode(token_secret) digest hmac.new( key.encode(utf-8), base_string.encode(utf-8), hashlib.sha1 ).digest() return base64.b64encode(digest).decode(utf-8)用的时候这样组织参数# 生成时间戳和 nonce timestamp str(int(time.time())) nonce .join(random.choices(string.ascii_letters string.digits, k16)) oauth_params { oauth_consumer_key: 你的_consumer_key, oauth_token: 你的_access_token, oauth_signature_method: HMAC-SHA1, oauth_timestamp: timestamp, oauth_nonce: nonce, oauth_version: 1.0, } # 业务参数GET 请求就来自 query string query_params { page: 1, status: paid, } signature hmac_sha1_sign( GET, https://api.example.com/v1/orders, query_params, oauth_params, client_secret你的_consumer_secret, token_secret你的_token_secret, ) # 签名值本身要做百分号编码后放进 Authorization 头 oauth_params[oauth_signature] rfc3986_encode(signature) auth_header OAuth , .join( f{k}{v} for k, v in oauth_params.items() )注意最后一个细节计算签名时 oauth_signature 必须排除在外算完之后再把签名值加回去而且签名值本身也要做一次百分号编码。因为 Base64 里可能包含、/、放进 HTTP 头之前必须转成安全字符RFC 5849 的示例里那个签名结尾的就被编码成了%3D。3.3 requests 生态的开箱即用方案手写实现能帮你理解原理但生产环境里我建议优先用 requests 的 OAuth1 认证模块它把上面这套繁琐的流程全部封装好了from requests_oauthlib import OAuth1 import requests auth OAuth1( client_key你的_consumer_key, client_secret你的_consumer_secret, resource_owner_key你的_access_token, resource_owner_secret你的_token_secret, signature_methodHMAC-SHA1, signature_typeAUTH_HEADER, rsa_keyNone, ) resp requests.get( https://api.example.com/v1/orders, params{page: 1, status: paid}, authauth, ) print(resp.status_code) print(resp.json())这个模块默认就会帮你生成时间戳和 nonce自动收集 params 里的业务参数参与签名发请求前把 Authorization 头拼好。但要注意如果业务参数是通过 data 以表单格式提交的Content-Type 为 application/x-www-form-urlencoded它也支持同时收集表单体和 query string 里的参数参与签名。如果参数是以 JSON 体提交的那 JSON 体不参与签名只有 query string 和 Authorization 头里的 OAuth 参数参与这个区别特别容易踩。4. PLAINTEXT 签名方法全解析4.1 签名的字面意义拼接即签名PLAINTEXT 这个名字很直白它根本不是传统意义上的签名没有哈希、没有摘要、没有 Base64。它的规则就是把客户端密钥和令牌密钥拼起来签名值 rfc3986_encode(client_secret) rfc3986_encode(token_secret)你没看错签名值等于签名密钥本身。这就意味着 PLAINTEXT 签名没有任何防篡改能力——任何拿到 consumer_secret 和 token_secret 的人都能伪造出一个合法的签名因为签名就是密钥本身。那它存在的意义是什么RFC 5849 自己都写了PLAINTEXT 方法不应在非安全通道上使用只有在传输层已经加密比如 HTTPS/TLS的环境下才考虑。换句话说它的安全不来自签名本身而来自底层的 TLS。在 TLS 的保护下客户端密钥和令牌密钥不会被窃听拼接密钥这个动作就变成了一种身份声明服务端比对密钥一致即通过。4.2 Python 实现与安全边界PLAINTEXT 的实现简单到让人怀疑是不是漏了步骤def plaintext_sign(client_secret, token_secret): PLAINTEXT 签名本质就是拼接密钥 return rfc3986_encode(client_secret) rfc3986_encode(token_secret) signature plaintext_sign(你的_consumer_secret, 你的_token_secret)如果你用的是 requests 的 OAuth1 模块把签名方法换成字符串即可auth OAuth1( client_key..., client_secret..., resource_owner_key..., resource_owner_secret..., signature_methodPLAINTEXT, )关于 PLAINTEXT 的安全边界我说几点实际判断依据。第一如果某个老接口要求 PLAINTEXT它八成要求所有请求必须走 HTTPS否则密钥在网络里裸奔。第二如果服务端给你发的令牌密钥里含有特殊字符记得按 RFC 3986 编码后再拼接不然密钥里带个会让服务端解析错位你会看到一堆莫名其妙的验签失败。第三很多平台虽然文档里写着支持 PLAINTEXT但实际上只是支持而已默认还是推荐 HMAC-SHA1因为 PLAINTEXT 过于简单出问题说不清楚。我自己的经验是能用 HMAC-SHA1 就绝对不用 PLAINTEXT只有在接口明确要求、或者客户端和服务端都在可信内网环境时才考虑后者。5. HMAC-SHA1 与 PLAINTEXT 的全面对比5.1 安全性从哈希对抗到明文暴露两种方法的安全模型天差地别。HMAC-SHA1 里即使攻击者截获了完整的签名基字符串和最终签名值他也无法反推出签名密钥——因为 HMAC 的单向性和密钥混淆机制保证了这一点。攻击者最多能做的是猜测密钥或者做离线暴力破解只要密钥强度够这条路基本走不通。PLAINTEXT 就完全不一样了签名值直接等于密钥拼接串截获到签名等于同时拿到了两个密钥。如果传输通道被中间人劫持整个授权体系直接沦陷。因此 PLAINTEXT 的安全性完全依附于 TLS一旦 TLS 被绕过或者配置错误它就没有任何兜底能力。还有一个经常被忽略的差异HMAC-SHA1 签名与请求内容强绑定请求参数被改一个字符签名就失效天然防篡改。PLAINTEXT 的签名值只跟密钥有关跟请求内容毫无关系攻击者如果拿到密钥可以随意篡改请求体再重新拼接签名服务端照样验签通过。5.2 场景匹配遗留接口该怎么选实际对接时选择哪种方法通常不是你能决定的而是服务端接口文档写死的。但理解它们的适用范围能帮你预判问题和做技术方案评估。对比维度HMAC-SHA1PLAINTEXT签名本质带密钥的哈希摘要密钥明文拼接防篡改能力强请求内容绑定无仅身份声明依赖传输层安全不强依赖但推荐 HTTPS强依赖必须 HTTPS签名长度固定 28 字符左右的 Base64随密钥长度变化抗重放能力配合 timestamp/honce 可防重放同样靠时间戳和 nonce计算开销需计算 SHA-1 哈希几乎为零适用场合一切需要真实验签的开放平台内网服务、传输层已强化的旧系统如果你的对接方是金融、支付、政务类的老网关大概率是 HMAC-SHA1。如果某个内部系统走的是局域网 HTTPS且要求实现成本最低PLAINTEXT 可能还在用。前几年我遇到一个很有意思的场景某个老平台支持两种签名但生产环境切到 PLAINTEXT 后经常出现超时重试导致订单重复下单的问题后来我分析了一下这不是签名算法本身的锅而是重试机制没有处理幂等时间戳和 nonce 重用导致服务端拒单。这个案例给我的教训是签名算法选型只是第一步配套的重试和幂等策略才是真正容易出事的环节。5.3 性能与实现成本性能上HMAC-SHA1 的 SHA-1 计算在现代 CPU 上大概是微秒到几十微秒级别对单个 HTTP 请求来说完全可以忽略。PLAINTEXT 省掉了哈希计算但在服务端压力测试中两者的吞吐量差异通常连 1% 都不到。真正影响性能的是验签时涉及的参数归一化、排序和字符串拼接这些环节两种算法一模一样。实现成本方面PLAINTEXT 确实低到离谱三五行业务代码就能搞定。但如果你用的是封装好的客户端模块切换到哪个算法都只是改一行配置的事。所以我的结论很明确除非接口文档明确要求 PLAINTEXT否则一律优先 HMAC-SHA1。安全余量更大出问题好排查也不会给人留下这个团队连签名都不懂的印象。6. 实战调试与高频踩坑记录6.1 签名不匹配的六大典型原因签名 401 是 OAuth 1.0a 对接里最常见的报错而且报错信息通常只有一句signature mismatch没有更多提示。我把这几年踩过的坑归纳成六大原因按出现频率排序序号原因具体表现1参数编码不一致空格被编成而不是%202参数字符串没有二次编码和在基字符串里没有变成%26和%3D3请求参数收集不全漏掉了 query string 里的业务参数或漏了表单体参数4密钥拼接错误token_secret 为空时漏掉了那个5URL 基础地址不一致带了默认端口、大小写不一致、多了尾部斜杠6参数排序错误没有按编码后的名字排序或名字相同时没按值排序其中第 2 条最隐蔽。很多人写完代码后自己先打印签名基字符串检查发现里面对比文档少了一层%26却不知道错在哪。这里提醒一句你在日志里看到的签名基字符串应该是 URL 和参数字符串都被编码后的形态参数字符串内部原本的分隔符在基字符串里就是%26。6.2 timestamp 与 nonce 的隐藏约束OAuth 1.0a 要求请求里必须带 oauth_timestamp 和 oauth_nonce这俩字段不是摆设。时间戳是 Unix 秒级时间服务端通常会校验它的新鲜度。我遇到过有的服务端把窗口设成 5 分钟有的设成 15 分钟还有极端的只认当前时间前后 30 秒。如果你的客户端机器时间和服务端差太多比如没做 NTP 同步的服务器签名会因为时间戳超窗而失败。排查这类问题时先看服务端日志里有没有 timestamp 相关的拒绝原因再用date %s对比一下本地和服务端的时间误差。nonce 的作用是防重放。同一个时间戳下nonce 应该是唯一的随机字符串。有的服务端会把用过的时间戳nonce 组合存起来遇到重复直接拒绝。这里有个实际坑如果你的 HTTP 客户端启用了自动重试而重试时复用了同一个请求对象时间戳和 nonce 也跟着复用了服务端就会把它判定为重放攻击。解决方法是每次重试都重新生成时间戳和 nonce或者在请求层做好幂等控制。6.3 一套可复用的排查流程签名对不上时别盲目改代码按下面这套流程走基本能在十分钟内定位问题。第一步抓出你自己生成的签名基字符串打印出来。第二步和服务端提供的验签日志如果对方愿意给的话逐字符比对。很多平台虽然不告诉你签名的具体参数但会在文档里提供一个签名生成示例你拿自己的请求参数跑一遍示例看结果是否一致。第三步如果中间对不上用分片法排查先只看 URL 部分编码对不对再看参数字符串排序对不对最后看是不是二次编码的问题。第四步如果签名基字符串完全一致但签名值还不对问题就出在密钥上——检查 consumer_secret 和 token_secret 是否填反了、是否多了空格、token_secret 为空时有没有保留。调试时我习惯写一个只打印不发送的函数把签名基字符串、签名密钥、最终签名值全部输出def debug_signature(method, url, query_params, oauth_params, client_secret, token_secret): base_string build_signature_base_string( method, url, query_params, oauth_params ) key rfc3986_encode(client_secret) rfc3986_encode(token_secret) signature base64.b64encode( hmac.new(key.encode(), base_string.encode(), hashlib.sha1).digest() ).decode() print( Signature Debug ) print(Base String:, base_string) print(Signing Key:, key) print(Signature:, signature) return signature这个函数被我写进过好几个项目的基础工具类里排查效率提升非常明显。还有一个经验网上有些教程会用 Python 的 dict 去保存参数但 Python 3.7 之后 dict 保持插入顺序这会让排序后拼接显得好像没问题一旦你改成手动拼接字符串就忘了排序。排序一定要显式做别依赖任何隐式顺序。踩过几次坑之后我个人的体会是OAuth 1.0a 签名最难的不是算法本身而是和对方保持完全一致。客户端和服务端是两套独立实现任何一处在编码、排序、收集参数上有细微差异签名就废了。所以对接前一定要先把对方的签名示例吃透把自己的实现打印出来逐字节比对不要想当然。这篇文章里的代码我都是按 RFC 5849 的规则写的你直接抄去用基本不会有问题但生产环境务必先用沙箱用例验证一遍确认无误再上线。