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))