PAYATHON 2026

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 必须同时出现在请求和排序后的待签名内容中。发送和签名缺少任意一处都会造成不一致。

排查顺序

  1. 打印响应状态、响应 body 和 Paytrail 返回的错误信息,不要直接只调用 raise_for_status()。
  2. 核对 URL、HTTP method 和 transaction ID。
  3. 检查 merchant ID 与 secret 是否配套,排除测试凭据和生产凭据混用。
  4. 使用 repr(signing_string) 检查换行、空格和空 body。
  5. 打印待签名字符串的 UTF-8 十六进制值,检查隐藏字符。
  6. 查看 prepared request 中实际发送的 checkout-* headers。
  7. 确认系统 UTC 时间准确,并为每次请求生成新的 nonce 和 timestamp。
  8. 使用 Paytrail 当前文档提供的固定输入或官方 SDK 生成 signature,再与 Python 结果逐字节比较。
  9. 关闭自动重定向,检查请求是否被代理或网关改写。
  10. 重试时重新生成整套认证 headers,不要复用旧 signature。

调试日志可以记录 signing string,但绝不能输出 secret。checkout-signature 也应视为敏感信息,排查结束后及时删除或脱敏。

备注:内容仅供参考。