简介
为了在保证支付安全的前提下,带给商户简单、一致且易用的开发体验,我们推出了全新的微信支付API v3。
其实还要一个主要因素是「为了符合监管的要求」。
主要是为了符合监管的要求,保证更高的安全级别。《中华人民共和国电子签名法》、《金融电子认证规范》及《非银行支付机构网络支付业务管理办法》中规定 “电子签名需要第三方认证的,由依法设立的电子认证服务提供者提供认证服务。”,所以需使用第三方 CA 来确保数字证书的唯一性、完整性及交易的不可抵赖性。
支付宝支付也是如此,从之前的「普通公钥方式」新增了 「公钥证书方式」。今天的主角是微信支付 Api v3 这里就不展开讲支付宝支付了。
微信支付 Api v3 接口规则 官方文档
v2 与 v3 的区别
V3 | 规则差异 | V2 |
---|---|---|
JSON | 参数格式 | XML |
POST、GET 或 DELETE | 提交方式 | POST |
AES-256-GCM加密 | 回调加密 | 无需加密 |
RSA 加密 | 敏感加密 | 无需加密 |
UTF-8 | 编码方式 | UTF-8 |
非对称密钥SHA256-RSA | 签名方式 | MD5 或 HMAC-SHA256 |
微信支付 Api-v2 版本详细介绍请参数之前博客 微信支付,你想知道的一切都在这里
干货多,屁话少,下面直接进入主题,读完全文你将 Get 到以下知识点
- 如何获取证书序列号
- 非对称密钥 SHA256-RSA 加密与验证签名
- AES-256-GCM 如何解密
API 密钥设置
请登录商户平台进入【账户中心】->【账户设置】->【API安全】->【APIv3密钥】中设置 API 密钥。
具体操作步骤请参见:什么是APIv3密钥?如何设置?
获取 API 证书
请登录商户平台进入【账户中心】->【账户设置】->【API安全】根据提示指引下载证书。
具体操作步骤请参见:什么是API证书?如何获取API证书?
按照以上步骤操作后你将获取如下内容:
- apiKey API 密钥
- apiKey3 APIv3 密钥
- mchId 商户号
- apiclient_key.pem X.509 标准证书的密钥
- apiclient_cert.p12 X.509 标准的证书+密钥
- apiclient_cert.pem X.509 标准的证书
请求签名
如何生成签名参数?官方文档 描述得非常清楚这里就不啰嗦了。
示例代码
构造签名串
/**
* 构造签名串
*
* @param method {@link RequestMethod} GET,POST,PUT等
* @param url 请求接口 /v3/certificates
* @param timestamp 获取发起请求时的系统当前时间戳
* @param nonceStr 随机字符串
* @param body 请求报文主体
* @return 待签名字符串
*/
public static String buildSignMessage(RequestMethod method, String url, long timestamp, String nonceStr, String body) {
return new StringBuilder()
.append(method.toString())
.append("\n")
.append(url)
.append("\n")
.append(timestamp)
.append("\n")
.append(nonceStr)
.append("\n")
.append(body)
.append("\n")
.toString();
}
构造 HTTP 头中的 Authorization
/**
* 构建 v3 接口所需的 Authorization
*
* @param method {@link RequestMethod} 请求方法
* @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接
* @param mchId 商户Id
* @param serialNo 商户 API 证书序列号
* @param keyPath key.pem 证书路径
* @param body 接口请求参数
* @param nonceStr 随机字符库
* @param timestamp 时间戳
* @param authType 认证类型
* @return {@link String} 返回 v3 所需的 Authorization
* @throws Exception 异常信息
*/
public static String buildAuthorization(RequestMethod method, String urlSuffix, String mchId,
String serialNo, String keyPath, String body, String nonceStr,
long timestamp, String authType) throws Exception {
// 构建签名参数
String buildSignMessage = PayKit.buildSignMessage(method, urlSuffix, timestamp, nonceStr, body);
// 获取商户私钥
String key = PayKit.getPrivateKey(keyPath);
// 生成签名
String signature = RsaKit.encryptByPrivateKey(buildSignMessage, key);
// 根据平台规则生成请求头 authorization
return PayKit.getAuthorization(mchId, serialNo, nonceStr, String.valueOf(timestamp), signature, authType);
}
/**
* 获取授权认证信息
*
* @param mchId 商户号
* @param serialNo 商户API证书序列号
* @param nonceStr 请求随机串
* @param timestamp 时间戳
* @param signature 签名值
* @param authType 认证类型,目前为WECHATPAY2-SHA256-RSA2048
* @return 请求头 Authorization
*/
public static String getAuthorization(String mchId, String serialNo, String nonceStr, String timestamp, String signature, String authType) {
Map<String, String> params = new HashMap<>(5);
params.put("mchid", mchId);
params.put("serial_no", serialNo);
params.put("nonce_str", nonceStr);
params.put("timestamp", timestamp);
params.put("signature", signature);
return authType.concat(" ").concat(createLinkString(params, ",", false, true));
}
拼接参数
public static String createLinkString(Map<String, String> params, String connStr, boolean encode, boolean quotes) {
List<String> keys = new ArrayList<String>(params.keySet());
Collections.sort(keys);
StringBuilder content = new StringBuilder();
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
String value = params.get(key);
// 拼接时,不包括最后一个&字符
if (i == keys.size() - 1) {
if (quotes) {
content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"');
} else {
content.append(key).append("=").append(encode ? urlEncode(value) : value);
}
} else {
if (quotes) {
content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"').append(connStr);
} else {
content.append(key).append("=").append(encode ? urlEncode(value) : value).append(connStr);
}
}
}
return content.toString();
}
从上面示例来看我们还差两个参数
- serial_no 证书序列号
- signature 使用商户私钥对待签名串进行 SHA256 with RSA 签名
如何获取呢?不要着急,容我喝杯 「89年的咖啡」提提神。
获取证书序列号
通过工具获取
- openssl x509 -in apiclient_cert.pem -noout -serial
- 使用证书解析工具 https://myssl.com/cert_decode.html
通过代码获取
// 获取证书序列号
X509Certificate certificate = PayKit.getCertificate(FileUtil.getInputStream("apiclient_cert.pem 证书路径"));
System.out.println("输出证书信息:\n" + certificate.toString());
System.out.println("证书序列号:" + certificate.getSerialNumber().toString(16));
System.out.println("版本号:" + certificate.getVersion());
System.out.println("签发者:" + certificate.getIssuerDN());
System.out.println("有效起始日期:" + certificate.getNotBefore());
System.out.println("有效终止日期:" + certificate.getNotAfter());
System.out.println("主体名:" + certificate.getSubjectDN());
System.out.println("签名算法:" + certificate.getSigAlgName());
System.out.println("签名:" + certificate.getSignature().toString());
/**
* 获取证书
*
* @param inputStream 证书文件
* @return {@link X509Certificate} 获取证书
*/
public static X509Certificate getCertificate(InputStream inputStream) {
try {
CertificateFactory cf = CertificateFactory.getInstance("X509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream);
cert.checkValidity();
return cert;
} catch (CertificateExpiredException e) {
throw new RuntimeException("证书已过期", e);
} catch (CertificateNotYetValidException e) {
throw new RuntimeException("证书尚未生效", e);
} catch (CertificateException e) {
throw new RuntimeException("无效的证书", e);
}
}
SHA256 with RSA 签名
获取商户私钥
/**
* 获取商户私钥
*
* @param keyPath 商户私钥证书路径
* @return 商户私钥
* @throws Exception 解析 key 异常
*/
public static String getPrivateKey(String keyPath) throws Exception {
String originalKey = FileUtil.readUtf8String(keyPath);
String privateKey = originalKey
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
return RsaKit.getPrivateKeyStr(RsaKit.loadPrivateKey(privateKey));
}
public static String getPrivateKeyStr(PrivateKey privateKey) {
return Base64.encode(privateKey.getEncoded());
}
/**
* 从字符串中加载私钥
*
* @param privateKeyStr 私钥
* @return {@link PrivateKey}
* @throws Exception 异常信息
*/
public static PrivateKey loadPrivateKey(String privateKeyStr) throws Exception {
try {
byte[] buffer = Base64.decode(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(buffer);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
return keyFactory.generatePrivate(keySpec);
} catch (NoSuchAlgorithmException e) {
throw new Exception("无此算法");
} catch (InvalidKeySpecException e) {
throw new Exception("私钥非法");
} catch (NullPointerException e) {
throw new Exception("私钥数据为空");
}
}
私钥签名
/**
* 私钥签名
*
* @param data 需要加密的数据
* @param privateKey 私钥
* @return 加密后的数据
* @throws Exception 异常信息
*/
public static String encryptByPrivateKey(String data, String privateKey) throws Exception {
PKCS8EncodedKeySpec priPkcs8 = new PKCS8EncodedKeySpec(Base64.decode(privateKey));
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey priKey = keyFactory.generatePrivate(priPkcs8);
java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA");
signature.initSign(priKey);
signature.update(data.getBytes(StandardCharsets.UTF_8));
byte[] signed = signature.sign();
return StrUtil.str(Base64.encode(signed));
}
至此微信支付 Api-v3 接口请求参数已封装完成。
执行请求
/**
* V3 接口统一执行入口
*
* @param method {@link RequestMethod} 请求方法
* @param urlPrefix 可通过 {@link WxDomain}来获取
* @param urlSuffix 可通过 {@link WxApiType} 来获取,URL挂载参数需要自行拼接
* @param mchId 商户Id
* @param serialNo 商户 API 证书序列号
* @param keyPath apiclient_key.pem 证书路径
* @param body 接口请求参数
* @param nonceStr 随机字符库
* @param timestamp 时间戳
* @param authType 认证类型
* @param file 文件
* @return {@link String} 请求返回的结果
* @throws Exception 接口执行异常
*/
public static Map<String, Object> v3Execution(RequestMethod method, String urlPrefix, String urlSuffix,
String mchId, String serialNo, String keyPath, String body,
String nonceStr, long timestamp, String authType,
File file) throws Exception {
// 构建 Authorization
String authorization = WxPayKit.buildAuthorization(method, urlSuffix, mchId, serialNo,
keyPath, body, nonceStr, timestamp, authType);
if (method == RequestMethod.GET) {
return doGet(urlPrefix.concat(urlSuffix), authorization, serialNo, null);
} else if (method == RequestMethod.POST) {
return doPost(urlPrefix.concat(urlSuffix), authorization, serialNo, body);
} else if (method == RequestMethod.DELETE) {
return doDelete(urlPrefix.concat(urlSuffix), authorization, serialNo, body);
} else if (method == RequestMethod.UPLOAD) {
return doUpload(urlPrefix.concat(urlSuffix), authorization, serialNo, body, file);
}
return null;
}
网络请求库默认是使用的 Hutool 封装的一套 Java 工具集合来实现
GET 请求
/**
* @param url 请求url
* @param authorization 授权信息
* @param serialNumber 公钥证书序列号
* @param jsonData 请求参数
* @return {@link HttpResponse} 请求返回的结果
*/
private HttpResponse doGet(String url, String authorization, String serialNumber, String jsonData) {
return HttpRequest.post(url)
.addHeaders(getHeaders(authorization, serialNumber))
.body(jsonData)
.execute();
}
POST 请求
/**
* @param url 请求url
* @param authorization 授权信息
* @param serialNumber 公钥证书序列号
* @param jsonData 请求参数
* @return {@link HttpResponse} 请求返回的结果
*/
private HttpResponse doPost(String url, String authorization, String serialNumber, String jsonData) {
return HttpRequest.post(url)
.addHeaders(getHeaders(authorization, serialNumber))
.body(jsonData)
.execute();
}
DELETE 请求
/**
* delete 请求
*
* @param url 请求url
* @param authorization 授权信息
* @param serialNumber 公钥证书序列号
* @param jsonData 请求参数
* @return {@link HttpResponse} 请求返回的结果
*/
private HttpResponse doDelete(String url, String authorization, String serialNumber, String jsonData) {
return HttpRequest.delete(url)
.addHeaders(getHeaders(authorization, serialNumber))
.body(jsonData)
.execute();
}
上传文件
/**
* @param url 请求url
* @param authorization 授权信息
* @param serialNumber 公钥证书序列号
* @param jsonData 请求参数
* @param file 上传的文件
* @return {@link HttpResponse} 请求返回的结果
*/
private HttpResponse doUpload(String url, String authorization, String serialNumber, String jsonData, File file) {
return HttpRequest.post(url)
.addHeaders(getUploadHeaders(authorization, serialNumber))
.form("file", file)
.form("meta", jsonData)
.execute();
}
构建 Http 请求头
private Map<String, String> getBaseHeaders(String authorization) {
String userAgent = String.format(
"WeChatPay-IJPay-HttpClient/%s (%s) Java/%s",
getClass().getPackage().getImplementationVersion(),
OS,
VERSION == null ? "Unknown" : VERSION);
Map<String, String> headers = new HashMap<>(3);
headers.put("Accept", ContentType.JSON.toString());
headers.put("Authorization", authorization);
headers.put("User-Agent", userAgent);
return headers;
}
private Map<String, String> getHeaders(String authorization, String serialNumber) {
Map<String, String> headers = getBaseHeaders(authorization);
headers.put("Content-Type", ContentType.JSON.toString());
if (StrUtil.isNotEmpty(serialNumber)) {
headers.put("Wechatpay-Serial", serialNumber);
}
return headers;
}
private Map<String, String> getUploadHeaders(String authorization, String serialNumber) {
Map<String, String> headers = getBaseHeaders(authorization);
headers.put("Content-Type", "multipart/form-data;boundary=\"boundary\"");
if (StrUtil.isNotEmpty(serialNumber)) {
headers.put("Wechatpay-Serial", serialNumber);
}
return headers;
}
构建 Http 请求返回值
从响应的 HttpResponse 中获取微信响应头信息、状态码以及 body
/**
* 构建返回参数
*
* @param httpResponse {@link HttpResponse}
* @return {@link Map}
*/
private Map<String, Object> buildResMap(HttpResponse httpResponse) {
Map<String, Object> map = new HashMap<>();
String timestamp = httpResponse.header("Wechatpay-Timestamp");
String nonceStr = httpResponse.header("Wechatpay-Nonce");
String serialNo = httpResponse.header("Wechatpay-Serial");
String signature = httpResponse.header("Wechatpay-Signature");
String body = httpResponse.body();
int status = httpResponse.getStatus();
map.put("timestamp", timestamp);
map.put("nonceStr", nonceStr);
map.put("serialNumber", serialNo);
map.put("signature", signature);
map.put("body", body);
map.put("status", status);
return map;
}
至此已完成构建请求参数,执行请求。接下来我们就要实现响应数据的解密以及响应结果的验证签名
对应的官方文档
验证签名
构建签名参数
/**
* 构造签名串
*
* @param timestamp 应答时间戳
* @param nonceStr 应答随机串
* @param body 应答报文主体
* @return 应答待签名字符串
*/
public static String buildSignMessage(String timestamp, String nonceStr, String body) {
return new StringBuilder()
.append(timestamp)
.append("\n")
.append(nonceStr)
.append("\n")
.append(body)
.append("\n")
.toString();
}
证书和回调报文解密
官方文档文末有完整的源码这里就不贴了。贴一个示例大家参数一下
try {
String associatedData = "certificate";
String nonce = "80d28946a64a";
String cipherText = "DwAqW4+4TeUaOEylfKEXhw+XqGh/YTRhUmLw/tBfQ5nM9DZ9d+9aGEghycwV1jwo52vXb/t6ueBvBRHRIW5JgDRcXmTHw9IMTrIK6HxTt2qiaGTWJU9whsF+GGeQdA7gBCHZm3AJUwrzerAGW1mclXBTvXqaCl6haE7AOHJ2g4RtQThi3nxOI63/yc3WaiAlSR22GuCpy6wJBfljBq5Bx2xXDZXlF2TNbDIeodiEnJEG2m9eBWKuvKPyUPyClRXG1fdOkKnCZZ6u+ipb4IJx28n3MmhEtuc2heqqlFUbeONaRpXv6KOZmH/IdEL6nqNDP2D7cXutNVCi0TtSfC7ojnO/+PKRu3MGO2Z9q3zyZXmkWHCSms/C3ACatPUKHIK+92MxjSQDc1E/8faghTc9bDgn8cqWpVKcL3GHK+RfuYKiMcdSkUDJyMJOwEXMYNUdseQMJ3gL4pfxuQu6QrVvJ17q3ZjzkexkPNU4PNSlIBJg+KX61cyBTBumaHy/EbHiP9V2GeM729a0h5UYYJVedSo1guIGjMZ4tA3WgwQrlpp3VAMKEBLRJMcnHd4pH5YQ/4hiUlHGEHttWtnxKFwnJ6jHr3OmFLV1FiUUOZEDAqR0U1KhtGjOffnmB9tymWF8FwRNiH2Tee/cCDBaHhNtfPI5129SrlSR7bZc+h7uzz9z+1OOkNrWHzAoWEe3XVGKAywpn5HGbcL+9nsEVZRJLvV7aOxAZBkxhg8H5Fjt1ioTJL+qXgRzse1BX1iiwfCR0fzEWT9ldDTDW0Y1b3tb419MhdmTQB5FsMXYOzqp5h+Tz1FwEGsa6TJsmdjJQSNz+7qPSg5D6C2gc9/6PkysSu/6XfsWXD7cQkuZ+TJ/Xb6Q1Uu7ZB90SauA8uPQUIchW5zQ6UfK5dwMkOuEcE/141/Aw2rlDqjtsE17u1dQ6TCax/ZQTDQ2MDUaBPEaDIMPcgL7fCeijoRgovkBY92m86leZvQ+HVbxlFx5CoPhz4a81kt9XJuEYOztSIKlm7QNfW0BvSUhLmxDNCjcxqwyydtKbLzA+EBb2gG4ORiH8IOTbV0+G4S6BqetU7RrO+/nKt21nXVqXUmdkhkBakLN8FUcHygyWnVxbA7OI2RGnJJUnxqHd3kTbzD5Wxco4JIQsTOV6KtO5c960oVYUARZIP1SdQhqwELm27AktEN7kzg/ew/blnTys/eauGyw78XCROb9F1wbZBToUZ7L+8/m/2tyyyqNid+sC9fYqJoIOGfFOe6COWzTI/XPytCHwgHeUxmgk7NYfU0ukR223RPUOym6kLzSMMBKCivnNg68tbLRJHEOpQTXFBaFFHt2qpceJpJgw5sKFqx3eQnIFuyvA1i8s2zKLhULZio9hpsDJQREOcNeHVjEZazdCGnbe3Vjg7uqOoVHdE/YbNzJNQEsB3/erYJB+eGzyFwFmdAHenG5RE6FhCutjszwRiSvW9F7wvRK36gm7NnVJZkvlbGwh0UHr0pbcrOmxT81xtNSvMzT0VZNLTUX2ur3AGLwi2ej8BIC0H41nw4ToxTnwtFR1Xy55+pUiwpB7JzraA08dCXdFdtZ72Tw/dNBy5h1P7EtQYiKzXp6rndfOEWgNOsan7e1XRpCnX7xoAkdPvy40OuQ5gNbDKry5gVDEZhmEk/WRuGGaX06CG9m7NfErUsnQYrDJVjXWKYuARd9R7W0aa5nUXqz/Pjul/LAatJgWhZgFBGXhNr9iAoade/0FPpBj0QWa8SWqKYKiOqXqhfhppUq35FIa0a1Vvxcn3E38XYpVZVTDEXcEcD0RLCu/ezdOa6vRcB7hjgXFIRZQAka0aXnQxwOZwE2Rt3yWXqc+Q1ah2oOrg8Lg3ETc644X9QP4FxOtDwz/A==";
AesUtil aesUtil = new AesUtil(wxPayV3Bean.getApiKey3().getBytes(StandardCharsets.UTF_8));
// 平台证书密文解密
// encrypt_certificate 中的 associated_data nonce ciphertext
String publicKey = aesUtil.decryptToString(
associatedData.getBytes(StandardCharsets.UTF_8),
nonce.getBytes(StandardCharsets.UTF_8),
cipherText
);
// 保存证书
FileWriter writer = new FileWriter(wxPayV3Bean.getPlatformCertPath());
writer.write(publicKey);
// 获取平台证书序列号
X509Certificate certificate = PayKit.getCertificate(new ByteArrayInputStream(publicKey.getBytes()));
return certificate.getSerialNumber().toString(16).toUpperCase();
} catch (Exception e) {
e.printStackTrace();
}
验证签名
/**
* 验证签名
*
* @param signature 待验证的签名
* @param body 应答主体
* @param nonce 随机串
* @param timestamp 时间戳
* @param certInputStream 微信支付平台证书输入流
* @return 签名结果
* @throws Exception 异常信息
*/
public static boolean verifySignature(String signature, String body, String nonce, String timestamp, InputStream certInputStream) throws Exception {
String buildSignMessage = PayKit.buildSignMessage(timestamp, nonce, body);
// 获取证书
X509Certificate certificate = PayKit.getCertificate(certInputStream);
PublicKey publicKey = certificate.getPublicKey();
return RsaKit.checkByPublicKey(buildSignMessage, signature, publicKey);
}
/**
* 公钥验证签名
*
* @param data 需要加密的数据
* @param sign 签名
* @param publicKey 公钥
* @return 验证结果
* @throws Exception 异常信息
*/
public static boolean checkByPublicKey(String data, String sign, PublicKey publicKey) throws Exception {
java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA");
signature.initVerify(publicKey);
signature.update(data.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.decode(sign.getBytes(StandardCharsets.UTF_8)));
}
至此微信支付 Api-v3 接口已介绍完,如有疑问欢迎留言一起探讨。
参考资料
看完后,默默点个赞
return "mchid=\"" + yourMerchantId + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + yourCertificateSerialNo + "\","
+ "signature=\"" + signature + "\""
yourMerchantId 如果是服务商模式这个是子商户号还是服务商的商户号
请问下,我们现在线上运行的是v2版本的接口,现在想升级到v3版本的,涉及到签名的问题,点这里的申请证书后,会对线上的版本有什么影响吗?之前的v2版本的接口,比如统一下单,也没用到证书。
请问下 我是不是只需要调WxPayKit 中的v3post方法 不需要关注接口规则了 就可以实现调v3的接口
具体使用方法可以参考 https://javen205.gitee.io/ijpay/guide/wxpay/api-v3.html
我找到大神写的源码包了,谢谢楼主分享!我这半吊子程序员,少一部分代码真是要了老命,结合源码与楼主的互补,总算不会报错了。我这里也分享一下大神源码及示例工程,小白可以参考一下: https://gitee.com/luozhizkang/IJPay
AesUtil aesUtil = new AesUtil(wxPayV3Bean.getApiKey3().getBytes(StandardCharsets.UTF_8))
请问这个构造方法从哪里来的?引用不到AesUtil 和 wxPayV3Bean,是不是少了什么包?
代码中有些引包不知道,几十个选项一个一个试也不对。文中还有些常量也没有给,看得很慌啊
代码中有些引包不知道,几十个选项一个一个试也不对。文中还有些常量也没有给,看得很慌啊
v3版本有h5支付示例吗?
博主代码有开源吗?