HMAC-SHA256 请求签名与验签实践(Python 可直接复用)

  • 目标:沉淀一套“能复制即用”的签名/验签规范与代码,解决接口防篡改与防伪造。
  • 关键规则:
    • 待签名串:METHOD + "\n" + Content-MD5 + "\n" + URI_PATH + [ "?" + sorted_query ]
    • Content-MD5:对“原始请求体字节”计算 MD5,hex 小写
    • Query:对 key 做升序排序;同 key 多值取第一个;拼接为 k=v&k2=v2
    • 签名:HMAC-SHA256(secret_bytes, utf8(string_to_sign)) → hex 小写

签名规范

  • 待签名串
    • METHOD 使用大写,如 POST、GET
    • URI_PATH 仅路径,不包含协议/域名/端口,例如 /open_api/query/template
    • 若无 query,省略 ? 部分
  • Content-MD5
    • 取“原始 body 字节”做 MD5;空 body 视为空字节
    • JSON 场景需使用“紧凑 JSON”序列化:separators=(',', ':'), ensure_ascii=False,UTF-8 编码
  • Query 处理
    • key 升序;多值取第一个;不做 URL 编码二次处理(与客户端保持一致)
  • 签名算法
    • HMAC-SHA256,密钥为字节串,输出十六进制小写

可直接复用代码(签名 + 验签)

import hmac
import hashlib
import json
from typing import Dict, Iterable, Tuple, Union, Optional
from urllib.parse import urlparse, parse_qs

ArgsType = Optional[Dict[str, Union[str, Iterable[str]]]]

def _compact_json_bytes(body: Optional[dict]) -> bytes:
    """
    将字典转为“紧凑 JSON”UTF-8字节;None/空字典→空字节
    """
    if not body:
        return b""
    raw = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
    return raw.encode("utf-8")

def _sorted_query(args: ArgsType) -> str:
    """
    将 query 参数按 key 升序拼接为 k=v&k2=v2。多值取第一个。
    None 或空返回 ""。
    """
    if not args:
        return ""
    keys = sorted(args.keys())
    pairs = []
    for k in keys:
        v = args[k]
        if isinstance(v, (list, tuple, set)):
            v = list(v)[0] if v else ""
        pairs.append(f"{k}={v}")
    return "&".join(pairs)

def build_string_to_sign(
    http_method: str,
    uri_path: str,
    args: ArgsType,
    body_bytes: bytes,
) -> Tuple[str, str]:
    """
    构造待签名串与 content_md5(hex 小写)
    返回: (string_to_sign, content_md5)
    """
    method = http_method.upper()
    content_md5 = hashlib.md5(body_bytes).hexdigest()
    qs = _sorted_query(args)
    uri_and_qs = f"{uri_path}?{qs}" if qs else uri_path
    string_to_sign = f"{method}\n{content_md5}\n{uri_and_qs}"
    return string_to_sign, content_md5

def generate_signature(
    http_method: str,
    uri_path: str,
    args: ArgsType,
    body: Optional[dict],
    secret: Union[str, bytes],
) -> Tuple[str, bytes, str]:
    """
    生成签名
    返回: (signature_hex, body_bytes, string_to_sign)
    """
    if isinstance(secret, str):
        secret = secret.encode("utf-8")

    body_bytes = _compact_json_bytes(body)
    string_to_sign, _ = build_string_to_sign(http_method, uri_path, args, body_bytes)
    signature = hmac.new(secret, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
    return signature, body_bytes, string_to_sign

def verify_signature(
    http_method: str,
    uri_path: str,
    args: ArgsType,
    raw_body_bytes: bytes,
    provided_signature_hex: str,
    secret: Union[str, bytes],
) -> bool:
    """
    验签。注意 raw_body_bytes 必须是请求体原始字节(未二次序列化)。
    """
    if isinstance(secret, str):
        secret = secret.encode("utf-8")
    string_to_sign, _ = build_string_to_sign(http_method, uri_path, args, raw_body_bytes)
    expected = hmac.new(secret, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
    # 常量时间比较,避免时序侧信道
    return hmac.compare_digest(provided_signature_hex, expected)

 

 

 

客户端请求示例(Python requests,可直接复制)

import os
import requests
from urllib.parse import urljoin, urlparse, parse_qs

# 引入上方的 generate_signature
# from sign_utils import generate_signature

BASE_URL = os.getenv("API_BASE_URL", "https://api.example.com")
URI_PATH = "/open_api/query/template"
URL = urljoin(BASE_URL, URI_PATH)

APP_ID = os.getenv("MY_APP_ID", "your_app_id_here")
SECRET = os.getenv("MY_SECRET", "your_secret_here")  # 建议使用环境变量配置

body = {"template_id": "your_template_id"}

# 解析 query(若 URL 上还有 ?k=v,可从这里带入参与签名)
args = parse_qs(urlparse(URL).query)

sign, body_bytes, string_to_sign = generate_signature(
    http_method="POST",
    uri_path=URI_PATH,
    args=args,
    body=body,
    secret=SECRET,
)

headers = {
    "WX-SIGN": sign,
    "WX-APPID": APP_ID,
    "Content-Type": "application/json; charset=utf-8",
    # "WX-DEBUG": "true",  # 调试时可开启
}

# 以原始字节发送,确保与签名一致
resp = requests.post(URL, data=body_bytes, headers=headers, timeout=10)
print("status:", resp.status_code)
print("resp:", resp.text)
# 调试输出
print("string_to_sign:", repr(string_to_sign))

 

 

 

 

posted on 2025-10-17 10:21  星河赵  阅读(7)  评论(0)    收藏  举报

导航