文章目錄
簡介
爲了在保證支付安全的前提下,帶給商戶簡單、一致且易用的開發體驗,我們推出了全新的微信支付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 接口已介紹完,如有疑問歡迎留言一起探討。
參考資料