Paytrail API HMAC authentication 返回 401 Unauthorized 如何排查?
结论
Paytrail 返回 401 Unauthorized,通常有两个原因:服务器根据收到的 checkout-* headers 重新计算 HMAC 后,结果与 checkout-signature 不一致;或者 checkout-account 和密钥并非同一套凭据。
GET 和 POST 使用相同的 HMAC 认证,不需要额外登录、交换 token 或执行其他认证流程。排查时应确认:
checkout-method与实际发送的 HTTP 方法一致,例如GET。- 待签名字符串包含除
checkout-signature外的所有checkout-*headers。 - Header 名称使用小写,并按字母顺序排列。
- 每行严格采用
name:value格式,不添加多余空格。 - GET 没有 body。待签名字符串末尾是否保留用于分隔空 body 的换行,应与 Paytrail 当前的 HMAC 规范和官方示例一致。
- merchant ID 和 secret 相互配套,并且属于当前调用环境。
- nonce、timestamp、method 等参与签名的值,与最终发送的值完全相同。
无 body 的 GET 通常不需要用 Content-Type: application/json 完成认证。由于该 header 不以 checkout- 开头,一般也不参与签名。
当前代码中需要检查的地方
1. merchant ID 与 secret 是否配套
凭据不匹配是常见原因。逐项检查:
MERCHANT_ID是否曾被读取为整数再转回字符串,造成格式变化。SECRET_KEY前后是否包含空格、换行或引号。- 测试凭据和生产凭据是否混用。
- 当前凭据是否属于
checkout-account指定的 merchant。 - 部署环境是否加载了预期的环境变量或配置项。
可以先记录不涉及完整密钥的信息:
print("merchant:", repr(self.merchant))
print("secret length:", len(self.secret))
不要把完整 secret 写入日志。repr() 可以帮助检查 merchant ID 中是否有隐藏空格。
初始化时可以清理配置值,但要先确认业务是否允许:
self.merchant = str(
credentials.credentials["MERCHANT_ID"]
).strip()
self.secret = credentials.credentials["SECRET_KEY"].strip()
如果 secret 理论上允许首尾空格,就不能直接调用 strip(),需要先核对凭据的原始值。
2. 签名内容与发送内容是否一致
HMAC 校验比较的是字节。字符串即使看起来相同,只要换行、空格、大小写或编码有一处不同,签名就会变化。
当前实现为 GET 生成的待签名字符串类似:
checkout-account:MERCHANT_ID
checkout-algorithm:sha256
checkout-method:GET
checkout-nonce:...
checkout-timestamp:...
代码中的 lines.append("") 会在字符串末尾保留一个换行符,因此实际参与 HMAC 的内容相当于:
checkout-account:MERCHANT_ID\n
checkout-algorithm:sha256\n
checkout-method:GET\n
checkout-nonce:...\n
checkout-timestamp:...\n
是否保留这个尾部换行,应根据 Paytrail 当前文档中的官方签名示例或测试向量判断。客户端和服务端对空 body 的处理只要不同,算出的签名就会完全不同。
调试时使用 repr(),这样才能看到末尾换行:
print("HMAC payload:", repr(signing_string))
print("HMAC payload bytes:", signing_string.encode("utf-8").hex())
3. 签名后是否修改过 header
以下情况都会让签名失效:
- 生成签名后修改
checkout-method。 - 重新生成 timestamp 或 nonce,却没有重新签名。
- 中间件、代理或 HTTP 客户端覆盖了参与签名的 header。
- 签名时使用
GET,请求重定向后变成其他方法。 - 签名时和发送时使用了不同的 headers。
发送请求前,应检查 requests 最终准备好的请求,而非只检查传给 requests.get() 的原始字典。
request = requests.Request(
method="GET",
url=f"{self.base_url}/payments/{reference}",
headers=headers,
)
prepared = request.prepare()
print("method:", prepared.method)
print("url:", prepared.url)
print("headers:")
for key, value in prepared.headers.items():
print(f" {key}: {value}")
print("body:", repr(prepared.body))
response = requests.Session().send(prepared, timeout=10)
通过这段代码可以确认 HTTP 客户端最终发送的内容。生产日志中需要隐藏 checkout-signature,也不能记录 secret。
4. timestamp 格式和服务器时间是否正确
Paytrail 会根据 checkout-timestamp 判断请求是否有效。当前写法通常能生成带毫秒和 Z 的 UTC 时间:
datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
使用带时区的 UTC 时间会更明确:
from datetime import datetime, timezone
timestamp = (
datetime.now(timezone.utc)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z")
)
同时检查运行环境的系统时间。服务器时间偏差较大时,即使 HMAC 算法正确,请求也可能被拒绝。容器、虚拟机和本地主机都应保持时间同步。
5. nonce 是否重复使用
每次请求都应生成新的 nonce,uuid.uuid4().hex 通常可以满足要求:
nonce = uuid.uuid4().hex
不要缓存整组认证 headers,也不要在重试时重复使用相同的 nonce 和 timestamp。每次重试都要重新生成 headers 和 signature。
6. HTTP 方法是否一致
checkout-method 必须与实际发送的方法完全一致:
checkout-method:GET
常见问题包括:
- 签名时传入包含空格的
"get "。 - 签名使用
POST,实际发送GET。 - 请求重定向后方法发生变化。
- 封装层修改了 method。
可以限制允许的方法,并在签名前统一格式:
method = method.strip().upper()
7. reference 是否用了错误的标识符
获取单笔支付状态时,路径参数通常应使用创建支付后返回的 transaction ID,而非商户自己的 reference 或 stamp。具体字段以当前使用的 Paytrail API 版本文档为准。
标识符错误更可能导致 404 或其他资源错误,通常不会引发 HMAC 认证错误,但仍值得检查:
transaction_id = creation_response["transactionId"]
随后调用:
GET /payments/{transactionId}
还要确认 ID 在 URL 拼接过程中没有被截断或额外编码。
便于调试的 HMAC 实现
下面的实现将待签名 headers、待签名字符串和最终 signature 分开,方便逐项比较:
import hashlib
import hmac
import uuid
from datetime import datetime, timezone
from typing import Optional
def generate_auth_headers(
merchant: str,
secret: str,
method: str,
body: Optional[str] = None,
) -> tuple[dict, str]:
method = method.strip().upper()
timestamp = (
datetime.now(timezone.utc)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z")
)
checkout_headers = {
"checkout-account": str(merchant),
"checkout-algorithm": "sha256",
"checkout-method": method,
"checkout-nonce": uuid.uuid4().hex,
"checkout-timestamp": timestamp,
}
header_lines = [
f"{name}:{value}"
for name, value in sorted(checkout_headers.items())
]
# 空 body 的具体拼接方式应与 Paytrail 当前规范保持一致。
# 这种写法会让 GET 的 signing_string 以 "\n" 结尾。
signing_string = "\n".join(header_lines + [body or ""])
signature = hmac.new(
key=secret.encode("utf-8"),
msg=signing_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
request_headers = {
**checkout_headers,
"checkout-signature": signature,
}
if body is not None:
request_headers["Content-Type"] = "application/json"
return request_headers, signing_string
GET 调用:
def get_payment_status(self, transaction_id: str) -> dict:
url = f"{self.base_url}/payments/{transaction_id}"
headers, signing_string = generate_auth_headers(
merchant=self.merchant,
secret=self.secret,
method="GET",
body=None,
)
print("URL:", url)
print("HMAC payload:", repr(signing_string))
print(
"checkout-signature:",
headers["checkout-signature"],
)
response = requests.get(
url,
headers=headers,
timeout=10,
allow_redirects=False,
)
if not response.ok:
print("status:", response.status_code)
print("response headers:", dict(response.headers))
print("response body:", response.text)
response.raise_for_status()
result = response.json()
status_mapping = {
"new": "INITIATED",
"ok": "PAID",
"fail": "FAILED",
"pending": "PENDING",
"delayed": "PENDING",
}
paytrail_status = result.get("status", "").lower()
return {
"id": transaction_id,
"status": status_mapping.get(paytrail_status, "UNKNOWN"),
"amount": result.get("amount"),
"captured": paytrail_status == "ok",
}
allow_redirects=False 用于排查重定向。如果响应状态是 301、302、307 或 308,先确认 URL 是否正确。原签名不能直接用于重定向后的地址。
POST 请求还要检查原始 body
POST 签名必须使用实际发送的 JSON 字符串。下面两段 JSON 的语义可能相同,但字节不同,因此 HMAC 也不同:
{"amount":100,"currency":"EUR"}
{
"amount": 100,
"currency": "EUR"
}
当前代码先完成一次序列化,再通过 data=body 发送同一个 body,这种处理可以保证签名和请求使用相同的字符串:
body = json.dumps(
payload,
separators=(",", ":"),
sort_keys=True,
)
headers = self._generate_auth_headers("POST", body)
response = requests.post(
url,
headers=headers,
data=body,
timeout=10,
)
签名后不要改用 json=payload。requests 可能重新序列化 JSON,使空格、字段顺序或字符转义方式与签名时不同。
编码也应明确为 UTF-8:
body_bytes = body.encode("utf-8")
signature = hmac.new(
secret.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
response = requests.post(
url,
headers=headers,
data=body_bytes,
timeout=10,
)
GET 是否需要额外 headers
一般情况下,GET 认证使用以下 headers:
checkout-account
checkout-algorithm
checkout-method
checkout-nonce
checkout-timestamp
checkout-signature
以下内容通常不需要:
- Bearer token
- Basic Authentication
- Cookie
- CSRF token
- 额外的登录请求
- 空 JSON body
Accept: application/json 可以用于内容协商,但通常不参与 HMAC:
headers["Accept"] = "application/json"
无 body 的 GET 通常也不需要 Content-Type。如果添加了该 header,并且 Paytrail 的签名规则仍规定只签署 checkout-* headers,就不应将 Content-Type 放入待签名字符串。
如果当前 Paytrail API 文档要求某个 endpoint 使用额外的 checkout-* header,这个 header 必须同时出现在请求和排序后的待签名内容中。发送和签名缺少任意一处都会造成不一致。
排查顺序
- 打印响应状态、响应 body 和 Paytrail 返回的错误信息,不要直接只调用
raise_for_status()。 - 核对 URL、HTTP method 和 transaction ID。
- 检查 merchant ID 与 secret 是否配套,排除测试凭据和生产凭据混用。
- 使用
repr(signing_string)检查换行、空格和空 body。 - 打印待签名字符串的 UTF-8 十六进制值,检查隐藏字符。
- 查看 prepared request 中实际发送的
checkout-*headers。 - 确认系统 UTC 时间准确,并为每次请求生成新的 nonce 和 timestamp。
- 使用 Paytrail 当前文档提供的固定输入或官方 SDK 生成 signature,再与 Python 结果逐字节比较。
- 关闭自动重定向,检查请求是否被代理或网关改写。
- 重试时重新生成整套认证 headers,不要复用旧 signature。
调试日志可以记录 signing string,但绝不能输出 secret。checkout-signature 也应视为敏感信息,排查结束后及时删除或脱敏。
备注:内容仅供参考。
