很多同学一看到"OAuth 签名"这几个字,下意识就默认这是 OAuth 2.0 的机制,实际上这里藏着一个非常经典的版本混淆。HMAC-SHA1 和 PLAINTEXT 这两种签名算法,出自 OAuth 1.0a 规范(RFC 5849),OAuth 2.0 里压根没有"签名方法"这个概念。标题里会出现"OAuth2 + HMAC-SHA1"这种混搭,多半是因为不少老系统的对接文档写着 OAuth 2.0,实际跑的却是 1.0a 的签名流程。这篇文章就专门把这件事彻底理清楚:两种签名算法各自的构造原理、Python 手写实现、什么时候该用哪个,以及我实际调接口时踩过的一堆坑。
这篇文章适合三类人:接老平台 OpenAPI 时被签名验证折磨的对接工程师,想手写 OAuth 1.0a 客户端而不是直接套库的底层开发者,以及面试前想搞明白签名基字符串到底怎么回事的同学。不需要你有密码学基础,只要写过 Python、见过 HTTP 请求,就能跟着走完。
1. 先理清版本:HMAC-SHA1 和 PLAINTEXT 到底属于哪个 OAuth
1.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 就成了hello+world,服务端按 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, k=16)) 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_method='HMAC-SHA1', signature_type='AUTH_HEADER', rsa_key=None, ) resp = requests.get( 'https://api.example.com/v1/orders', params={'page': '1', 'status': 'paid'}, auth=auth, ) 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_method='PLAINTEXT', )关于 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-SHA1 | PLAINTEXT |
|---|---|---|
| 签名本质 | 带密钥的哈希摘要 | 密钥明文拼接 |
| 防篡改能力 | 强,请求内容绑定 | 无,仅身份声明 |
| 依赖传输层安全 | 不强依赖,但推荐 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 | 参数编码不一致 | 空格被编成+而不是%20 |
| 2 | 参数字符串没有二次编码 | &和=在基字符串里没有变成%26和%3D |
| 3 | 请求参数收集不全 | 漏掉了 query string 里的业务参数,或漏了表单体参数 |
| 4 | 密钥拼接错误 | token_secret 为空时漏掉了那个& |
| 5 | URL 基础地址不一致 | 带了默认端口、大小写不一致、多了尾部斜杠 |
| 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 的规则写的,你直接抄去用基本不会有问题,但生产环境务必先用沙箱用例验证一遍,确认无误再上线。