splitaccount_tool.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. # coding=utf-8
  2. import os
  3. import uuid
  4. import requests
  5. import json
  6. import xmltodict
  7. import time
  8. from datetime import datetime
  9. from hashlib import md5
  10. from django.conf import settings
  11. from util.exceptions import CustomError
  12. from cryptography.exceptions import InvalidSignature, InvalidTag
  13. from cryptography.hazmat.backends import default_backend
  14. from cryptography.hazmat.primitives.asymmetric.padding import MGF1, OAEP, PKCS1v15
  15. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  16. from cryptography.hazmat.primitives.hashes import SHA1, SHA256
  17. from cryptography.hazmat.primitives.hmac import HMAC
  18. from cryptography.hazmat.primitives.serialization import load_pem_private_key
  19. from cryptography.x509 import load_pem_x509_certificate
  20. from base64 import b64decode, b64encode
  21. class SplitAccountTool(object):
  22. '''直连服务商 分账 文档https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml'''
  23. GET = 'GET'
  24. POST = 'POST'
  25. def __init__(self, appid, mchid, private_key, cert_serial_no, apiv3_key, cert_dir=None, proxy=None):
  26. self._mchid = mchid # 商户号
  27. self._appid = appid
  28. self._private_key = load_private_key(private_key) # 商户证书私钥
  29. self._cert_serial_no = cert_serial_no # 商户证书序列号
  30. self._apiv3_key = apiv3_key # 商户APIv3密钥
  31. self._cert_dir = cert_dir # 平台证书存放目录
  32. self._gate_way = 'https://api.mch.weixin.qq.com'
  33. self._proxy = proxy
  34. self._certificates = []
  35. self._init_certificates()
  36. def _init_certificates(self):
  37. if self._cert_dir and os.path.exists(self._cert_dir):
  38. for file_name in os.listdir(self._cert_dir):
  39. if not file_name.lower().endswith('.pem'):
  40. continue
  41. with open(self._cert_dir + file_name, encoding="utf-8") as f:
  42. certificate = load_certificate(f.read())
  43. now = datetime.utcnow()
  44. if certificate and now >= certificate.not_valid_before and now <= certificate.not_valid_after:
  45. self._certificates.append(certificate)
  46. if not self._certificates:
  47. self._update_certificates()
  48. if not self._certificates:
  49. raise CustomError('没有wechatpay平台证书,请仔细检查您的初始化参数!')
  50. def _update_certificates(self):
  51. path = '/v3/certificates'
  52. self._certificates.clear()
  53. code, message = self.request(path, skip_verify=True)
  54. if code != 200:
  55. return
  56. data = json.loads(message).get('data')
  57. for value in data:
  58. serial_no = value.get('serial_no')
  59. effective_time = value.get('effective_time')
  60. expire_time = value.get('expire_time')
  61. encrypt_certificate = value.get('encrypt_certificate')
  62. algorithm = nonce = associated_data = ciphertext = None
  63. if encrypt_certificate:
  64. algorithm = encrypt_certificate.get('algorithm')
  65. nonce = encrypt_certificate.get('nonce')
  66. associated_data = encrypt_certificate.get('associated_data')
  67. ciphertext = encrypt_certificate.get('ciphertext')
  68. if not (serial_no and effective_time and expire_time and algorithm and nonce and associated_data and ciphertext):
  69. continue
  70. cert_str = aes_decrypt(
  71. nonce=nonce,
  72. ciphertext=ciphertext,
  73. associated_datformat_private_keya=associated_data,
  74. apiv3_key=self._apiv3_key)
  75. certificate = load_certificate(cert_str)
  76. if not certificate:
  77. continue
  78. now = datetime.utcnow()
  79. if now < certificate.not_valid_before or now > certificate.not_valid_after:
  80. continue
  81. self._certificates.append(certificate)
  82. if not self._cert_dir:
  83. continue
  84. if not os.path.exists(self._cert_dir):
  85. os.makedirs(self._cert_dir)
  86. if not os.path.exists(self._cert_dir + serial_no + '.pem'):
  87. f = open(self._cert_dir + serial_no + '.pem', 'w')
  88. f.write(cert_str)
  89. f.close()
  90. def _verify_signature(self, headers, body):
  91. signature = headers.get('Wechatpay-Signature')
  92. timestamp = headers.get('Wechatpay-Timestamp')
  93. nonce = headers.get('Wechatpay-Nonce')
  94. serial_no = headers.get('Wechatpay-Serial')
  95. cert_found = False
  96. for cert in self._certificates:
  97. if int('0x' + serial_no, 16) == cert.serial_number:
  98. cert_found = True
  99. certificate = cert
  100. break
  101. if not cert_found:
  102. self._update_certificates()
  103. for cert in self._certificates:
  104. if int('0x' + serial_no, 16) == cert.serial_number:
  105. cert_found = True
  106. certificate = cert
  107. break
  108. if not cert_found:
  109. return False
  110. if not rsa_verify(timestamp, nonce, body, signature, certificate):
  111. return False
  112. return True
  113. def rsa_sign(self, private_key, sign_str):
  114. message = sign_str.encode('UTF-8')
  115. signature = private_key.sign(data=message, padding=PKCS1v15(), algorithm=SHA256())
  116. sign = b64encode(signature).decode('UTF-8').replace('\n', '')
  117. return sign
  118. def build_authorization(self, path, method, mchid, serial_no, private_key, data=None, nonce_str=None):
  119. '''
  120. 一、构建签名串
  121. 签名串一共有五行,每一行为一个参数。行尾以 \n(换行符,ASCII编码值为0x0A)结束,包括最后一行。如果参数本身以\n结束,也需要附加一个\n。
  122. 第一步,获取HTTP请求的方法(GET, POST, PUT)等
  123. 第二步,获取请求的绝对URL,并去除域名部分得到参与签名的URL。如果请求中有查询参数,URL末尾应附加有'?'和对应的查询字符串。
  124. 第三步,获取发起请求时的系统当前时间戳,即格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数,作为请求时间戳。微信支付会拒绝处理很久之前发起的请求,请商户保持自身系统的时间准确。
  125. 第四步,生成一个请求随机串,可参见生成随机数算法。这里,我们使用命令行直接生成一个。
  126. 第五步,获取请求中的请求报文主体(request body)。
  127. 请求方法为GET时,报文主体为空。
  128. 当请求方法为POST或PUT时,请使用真实发送的JSON报文。
  129. 图片上传API,请使用meta对应的JSON报文。
  130. 对于下载证书的接口来说,请求报文主体是一个空串。
  131. 二、计算签名值
  132. 绝大多数编程语言提供的签名函数支持对签名数据进行签名。强烈建议商户调用该类函数,使用商户私钥对待签名串进行SHA256 with RSA签名,并对签名结果进行Base64编码得到签名值。
  133. 三、设置HTTP头
  134. 微信支付商户API V3要求通过HTTP Authorization头来传递签名 Authorization由认证类型和签名信息两部分组成
  135. 1.认证类型,目前为WECHATPAY2-SHA256-RSA2048
  136. 2.签名信息
  137. 发起请求的商户(包括直连商户、服务商或渠道商)的商户号 mchid
  138. 商户API证书序列号serial_no,用于声明所使用的证书
  139. 请求随机串nonce_str
  140. 时间戳timestamp
  141. 签名值signature
  142. 注:以上五项签名信息,无顺序要求。
  143. '''
  144. timeStamp = str(int(time.time()))
  145. nonce_str = nonce_str or ''.join(str(uuid.uuid4()).split('-')).upper()
  146. body = data if isinstance(data, str) else json.dumps(data) if data else ''
  147. sign_str = '%s\n%s\n%s\n%s\n%s\n' % (method, path, timeStamp, nonce_str, body)
  148. signature = self.rsa_sign(private_key=private_key, sign_str=sign_str)
  149. authorization = 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"' % (
  150. mchid, nonce_str, signature, timeStamp, serial_no)
  151. return authorization
  152. def request(self, path, method=GET, data=None, skip_verify=False, sign_data=None, files=None, cipher_data=False, headers={}):
  153. if files:
  154. headers.update({'Content-Type': 'multipart/form-data'})
  155. else:
  156. headers.update({'Content-Type': 'application/json'})
  157. headers.update({'Accept': 'application/json'})
  158. authorization = self.build_authorization(path, method, self._mchid, self._cert_serial_no, self._private_key, data=sign_data if sign_data else data)
  159. headers.update({'Authorization': authorization})
  160. if method == SplitAccountTool.GET:
  161. response = requests.get(url=self._gate_way + path, headers=headers, proxies=self._proxy)
  162. elif method == SplitAccountTool.POST:
  163. response = requests.post(url=self._gate_way + path, json=None if files else data, data=data if files else None, headers=headers, files=files, proxies=self._proxy)
  164. else:
  165. raise CustomError('请选择正确的请求方式!')
  166. if response.status_code in range(200, 300) and not skip_verify:
  167. if not self._verify_signature(response.headers, response.text):
  168. raise CustomError('签名验证失败!')
  169. return response.status_code, response.text if 'application/json' in response.headers.get('Content-Type') else response.content
  170. def format_private_key(private_key_str):
  171. pem_start = '-----BEGIN PRIVATE KEY-----\n'
  172. pem_end = '\n-----END PRIVATE KEY-----'
  173. if not private_key_str.startswith(pem_start):
  174. private_key_str = pem_start + private_key_str
  175. if not private_key_str.endswith(pem_end):
  176. private_key_str = private_key_str + pem_end
  177. return private_key_str
  178. def load_private_key(private_key_str):
  179. try:
  180. return load_pem_private_key(data=format_private_key(private_key_str).encode('UTF-8'), password=None, backend=default_backend())
  181. except:
  182. raise CustomError('商户证书私钥加载失败!')
  183. def rsa_verify(timestamp, nonce, body, signature, certificate):
  184. '''验证签名'''
  185. sign_str = '%s\n%s\n%s\n' % (timestamp, nonce, body)
  186. public_key = certificate.public_key()
  187. message = sign_str.encode('UTF-8')
  188. signature = b64decode(signature)
  189. try:
  190. public_key.verify(signature, message, PKCS1v15(), SHA256())
  191. except InvalidSignature:
  192. return False
  193. return True
  194. def load_certificate(certificate_str):
  195. try:
  196. return load_pem_x509_certificate(data=certificate_str.encode('UTF-8'), backend=default_backend())
  197. except:
  198. return None
  199. def aes_decrypt(nonce, ciphertext, associated_data, apiv3_key):
  200. '''回调信息解密'''
  201. key_bytes = apiv3_key.encode('UTF-8')
  202. nonce_bytes = nonce.encode('UTF-8')
  203. associated_data_bytes = associated_data.encode('UTF-8')
  204. data = b64decode(ciphertext)
  205. aesgcm = AESGCM(key=key_bytes)
  206. try:
  207. result = aesgcm.decrypt(nonce=nonce_bytes, data=data, associated_data=associated_data_bytes).decode('UTF-8')
  208. except InvalidTag:
  209. result = None
  210. return result