鍵.png)
node.js + express + docker + mysql + jwt 實(shí)現(xiàn)用戶管理restful api
今天來分享微信支付的難點(diǎn)——簽名,雖然有很多好用的 SDK 但是如果你想深入了解微信支付還是有幫助的。
為了保證資金敏感數(shù)據(jù)的安全性,確保我們業(yè)務(wù)中的資金往來交易萬無一失。目前微信支付第三方簽發(fā)的權(quán)威的 CA 證書(API 證書)中提供的私鑰來進(jìn)行簽名。通過商戶平臺(tái)你可以設(shè)置并獲取 API 證書。
切記在第一次設(shè)置的時(shí)候會(huì)提示下載,后面就不再提供下載了,具體參考說明。
設(shè)置后找到zip
壓縮包解壓,里面有很多文件,對(duì)于 JAVA 開發(fā)來說只需要關(guān)注apiclient_cert.p12
這個(gè)證書文件就行了,它包含了公私鑰
,我們需要把它放在服務(wù)端并利用 Java 解析.p12
文件獲取公鑰私鑰。
務(wù)必保證證書在服務(wù)器端的安全,它涉及到資金安全。
接下來就是證書的解析了,證書的解析有網(wǎng)上很多方法,這里我使用比較“正規(guī)”的方法來解析,利用 JDK 安全包的java.security.KeyStore
來解析。
微信支付 API 證書使用了PKCS12
算法,我們通過KeyStore
來獲取公私鑰對(duì)的載體KeyPair
以及證書序列號(hào)serialNumber
,我封裝了工具類(序列號(hào)你自己處理):
import org.springframework.core.io.ClassPathResource;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
/**
* KeyPairFactory
*
* @author dax
* @since 13:41
**/
public class KeyPairFactory {
private KeyStore store;
private final Object lock = new Object();
/**
* 獲取公私鑰.
*
* @param keyPath the key path
* @param keyAlias the key alias
* @param keyPass password
* @return the key pair
*/
public KeyPair createPKCS12(String keyPath, String keyAlias, String keyPass) {
ClassPathResource resource = new ClassPathResource(keyPath);
char[] pem = keyPass.toCharArray();
try {
synchronized (lock) {
if (store == null) {
synchronized (lock) {
store = KeyStore.getInstance("PKCS12");
store.load(resource.getInputStream(), pem);
}
}
}
X509Certificate certificate = (X509Certificate) store.getCertificate(keyAlias);
certificate.checkValidity();
// 證書的序列號(hào) 也有用
String serialNumber = certificate.getSerialNumber().toString(16).toUpperCase();
// 證書的 公鑰
PublicKey publicKey = certificate.getPublicKey();
// 證書的私鑰
PrivateKey storeKey = (PrivateKey) store.getKey(keyAlias, pem);
return new KeyPair(publicKey, storeKey);
} catch (Exception e) {
throw new IllegalStateException("Cannot load keys from store: " + resource, e);
}
}
}
眼熟的可以看出是胖哥 Spring Security 教程中 JWT 用的公私鑰提取方法的修改版本,你可以對(duì)比下不同之處。
這個(gè)方法中有三個(gè)參數(shù),這里必須要說明一下:
keyPath
API 證書apiclient_cert.p12
的classpath
路徑,一般我們會(huì)放在resources
路徑下,當(dāng)然你可以修改獲取證書輸入流的方式。keyAlias
證書的別名,這個(gè)微信的文檔是沒有的,胖哥通過加載證書時(shí)進(jìn)行 DEBUG 獲取到該值固定為Tenpay Certificate
。keyPass
證書密碼,這個(gè)默認(rèn)就是商戶號(hào),在其它配置中也需要使用就是mchid
,就是你用超級(jí)管理員登錄微信商戶平臺(tái)在個(gè)人資料中的一串?dāng)?shù)字。微信支付 V3 版本的簽名是我們?cè)谡{(diào)用具體的微信支付的 API 時(shí)在 HTTP 請(qǐng)求頭中攜帶特定的編碼串供微信支付服務(wù)器進(jìn)行驗(yàn)證請(qǐng)求來源,確保請(qǐng)求是真實(shí)可信的。
簽名串的具體格式,一共五行一行也不能少,每一行以換行符\n
結(jié)束。
HTTP請(qǐng)求方法\n
URL\n
請(qǐng)求時(shí)間戳\n
請(qǐng)求隨機(jī)串\n
請(qǐng)求報(bào)文主體\n
POST
。https://api.mch.weixin.qq.com/v3/pay/transactions/app
,除去域名部分得到參與簽名的 URL。如果請(qǐng)求中有查詢參數(shù),URL 末尾應(yīng)附加有’?’和對(duì)應(yīng)的查詢字符串。這里為/v3/pay/transactions/app
。System.currentTimeMillis() / 1000
獲取即可。593BEC0C930BF1AFEB40B4A08C8FB242
的字符串就行了。""
;當(dāng)請(qǐng)求方法為POST
或PUT
時(shí),請(qǐng)使用真實(shí)發(fā)送的JSON
報(bào)文。圖片上傳 API,請(qǐng)使用meta
對(duì)應(yīng)的JSON
報(bào)文。然后我們使用商戶私鑰對(duì)按照上面格式的待簽名串進(jìn)行 SHA256 with RSA 簽名,并對(duì)簽名結(jié)果進(jìn)行Base64 編碼得到簽名值。對(duì)應(yīng)的核心 Java 代碼為:
/**
* V3 SHA256withRSA 簽名.
*
* @param method 請(qǐng)求方法 GET POST PUT DELETE 等
* @param canonicalUrl 例如 https://api.mch.weixin.qq.com/v3/pay/transactions/app?version=1 ——> /v3/pay/transactions/app?version=1
* @param timestamp 當(dāng)前時(shí)間戳 因?yàn)橐渲玫絋OKEN 中所以 簽名中的要跟TOKEN 保持一致
* @param nonceStr 隨機(jī)字符串 要和TOKEN中的保持一致
* @param body 請(qǐng)求體 GET 為 "" POST 為JSON
* @param keyPair 商戶API 證書解析的密鑰對(duì) 實(shí)際使用的是其中的私鑰
* @return the string
*/
@SneakyThrows
String sign(String method, String canonicalUrl, long timestamp, String nonceStr, String body, KeyPair keyPair) {
String signatureStr = Stream.of(method, canonicalUrl, String.valueOf(timestamp), nonceStr, body)
.collect(Collectors.joining("\n", "", "\n"));
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initSign(keyPair.getPrivate());
sign.update(signatureStr.getBytes(StandardCharsets.UTF_8));
return Base64Utils.encodeToString(sign.sign());
}
簽名生成后會(huì)同一些參數(shù)組成一個(gè)Token
放置到對(duì)應(yīng) HTTP 請(qǐng)求的Authorization
請(qǐng)求頭中,格式為:
Authorization: WECHATPAY2-SHA256-RSA2048 {Token}
Token
由以下五部分組成:
mchid
serial_no
,用于聲明所使用的證書nonce_str
timestamp
signature
Token
生成的核心代碼:
/**
* 生成Token.
*
* @param mchId 商戶號(hào)
* @param nonceStr 隨機(jī)字符串
* @param timestamp 時(shí)間戳
* @param serialNo 證書序列號(hào)
* @param signature 簽名
* @return the string
*/
String token(String mchId, String nonceStr, long timestamp, String serialNo, String signature) {
final String TOKEN_PATTERN = "mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\"";
// 生成token
return String.format(TOKEN_PATTERN,
wechatPayProperties.getMchId(),
nonceStr, timestamp, serialNo, signature);
}
將生成的Token
按照上述格式放入請(qǐng)求頭中即可完成簽名的使用。
本文我們對(duì)微信支付 V3 版本的難點(diǎn)簽名以及簽名的使用進(jìn)行了完整的分析,同時(shí)對(duì) API 證書的解析也進(jìn)行了講解,相信能夠幫助你在支付開發(fā)中解決一些具體的問題。
在Java 中的微信支付(1):API V3 版本簽名詳解一文中胖哥講解了微信支付 V3 版本 API 的簽名,當(dāng)我方(你自己的服務(wù)器)請(qǐng)求微信支付服務(wù)器時(shí)需要根據(jù)我方的API 證書對(duì)參數(shù)進(jìn)行加簽,微信服務(wù)器會(huì)根據(jù)我方簽名驗(yàn)簽以確定請(qǐng)求來自我方服務(wù)器。那么同樣的道理我方的服務(wù)器也要對(duì)微信支付服務(wù)器的響應(yīng)進(jìn)行鑒別來確定響應(yīng)真的來自微信支付服務(wù)器,這就是驗(yàn)簽。驗(yàn)簽使用的是【微信支付平臺(tái)證書公鑰】,不是商戶 API 證書。使用商戶 API 證書是驗(yàn)證不過的。今天就來分享一下如何獲得微信平臺(tái)公鑰和動(dòng)態(tài)刷新微信平臺(tái)公鑰。
微信平臺(tái)證書是微信支付平臺(tái)自己的證書,我們是管不了的,而且是有效期的。
微信服務(wù)器會(huì)定期更換,所以也要求我方定期獲取公鑰。而且我們只能通過調(diào)用接口/v3/certificates
來獲得,此接口也需要進(jìn)行簽名(可參考上一篇文章)。你可以獲取證書后靜態(tài)放到服務(wù)器上,手動(dòng)更新靜態(tài)證書;也可以動(dòng)態(tài)獲取一勞永逸。本文采取一勞永逸的辦法。
平臺(tái)證書接口文檔:https://wechatpay-api.gitbook.io/wechatpay-api-v3/jie-kou-wen-dang/ping-tai-zheng-shu
為了保證安全性,微信支付在回調(diào)通知和平臺(tái)證書下載接口中,對(duì)關(guān)鍵信息進(jìn)行了AES-256-GCM
加密。也就是說我們拿到響應(yīng)的信息是被加密的,需要解密后才能獲得真正的微信平臺(tái)證書公鑰。響應(yīng)體大致是這樣的,具體根據(jù)你調(diào)用平臺(tái)證書接口,應(yīng)該大差不差是下面這個(gè)結(jié)構(gòu):
{
"data": [
{
"effective_time": "2020-10-21T14:48:49+08:00",
"encrypt_certificate": {
// 加密算法
"algorithm": "AEAD_AES_256_GCM",
// 附加數(shù)據(jù)包(可能為空)
"associated_data": "certificate",
// Base64編碼后的密文
"ciphertext": "",
// 加密使用的隨機(jī)串初始化向量)
"nonce": "88b4e15a0db9"
},
"expire_time": "2025-10-20T14:48:49+08:00",
// 證書序列號(hào)
"serial_no": "217016F42805DD4D5442059D373F98BFC5252599"
}
]
}
你可以使用各種 JSON 類庫取得下面方法的參數(shù)進(jìn)行解密以獲取證書,同時(shí)這里需要用到APIv3密鑰
,通用的解密方式為:
/**
* 解密響應(yīng)體.
*
* @param apiV3Key API V3 KEY API v3密鑰 商戶平臺(tái)設(shè)置的32位字符串
* @param associatedData response.body.data[i].encrypt_certificate.associated_data
* @param nonce response.body.data[i].encrypt_certificate.nonce
* @param ciphertext response.body.data[i].encrypt_certificate.ciphertext
* @return the string
* @throws GeneralSecurityException the general security exception
*/
public String decryptResponseBody(String apiV3Key,String associatedData, String nonce, String ciphertext) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec key = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES");
GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8));
cipher.init(Cipher.DECRYPT_MODE, key, spec);
cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
byte[] bytes;
try {
bytes = cipher.doFinal(Base64Utils.decodeFromString(ciphertext));
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException(e);
}
return new String(bytes, StandardCharsets.UTF_8);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException(e);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalArgumentException(e);
}
}
回調(diào)的請(qǐng)求體也是此方法進(jìn)行解密。
然后就能拿到微信平臺(tái)證書公鑰。然后你可以定義個(gè) Map,以證書的序列號(hào)為 KEY,以證書為 Value 來動(dòng)態(tài)刷新,關(guān)鍵偽代碼:
// 定義全局容器 保存微信平臺(tái)證書公鑰 注意線程安全
private static final Map<String, Certificate> CERTIFICATE_MAP = new ConcurrentHashMap<>();
// 下面是刷新方法 refreshCertificate 的核心代碼
String publicKey = decryptResponseBody(associatedData, nonce, ciphertext);
final CertificateFactory cf = CertificateFactory.getInstance("X509");
ByteArrayInputStream inputStream = new ByteArrayInputStream(publicKey.getBytes(StandardCharsets.UTF_8));
Certificate certificate = null;
try {
certificate = cf.generateCertificate(inputStream);
} catch (CertificateException e) {
e.printStackTrace();
}
String responseSerialNo = objectNode.get("serial_no").asText();
// 清理HashMap
CERTIFICATE_MAP.clear();
// 放入證書
CERTIFICATE_MAP.put(responseSerialNo, certificate);
動(dòng)態(tài)刷新的策略就很好寫了:
// 當(dāng)證書容器為空 或者 響應(yīng)提供的證書序列號(hào)不在容器中時(shí) 就應(yīng)該刷新了
if (CERTIFICATE_MAP.isEmpty() || !CERTIFICATE_MAP.containsKey(wechatpaySerial)) {
refreshCertificate();
}
// 然后調(diào)用
Certificate certificate = CERTIFICATE_MAP.get(wechatpaySerial);
雖然驗(yàn)簽?zāi)悴蛔隹梢阅玫狡渌涌诘捻憫?yīng)結(jié)果,但是從資金安全的角度來說這是十分必要的。同時(shí)因?yàn)槲⑿牌脚_(tái)證書不收我方控制,采取動(dòng)態(tài)刷新也會(huì)更加方便,不必再擔(dān)心過期的問題。本文我們通過調(diào)用接口拿到密文并解密獲得證書。下一篇我們將通過獲得的證書進(jìn)行簽名驗(yàn)證來確保我們的響應(yīng)是微信服務(wù)器發(fā)過來的。
微信支付 V3 版本前兩篇分別講了如何對(duì)請(qǐng)求做簽名和如何獲取并刷新微信平臺(tái)公鑰,本篇將繼續(xù)展開如何對(duì)微信支付響應(yīng)結(jié)果的驗(yàn)簽。
微信支付會(huì)在回調(diào)的 HTTP 頭部中包括回調(diào)報(bào)文的簽名。商戶必須驗(yàn)證響應(yīng)的簽名,保證響應(yīng)確實(shí)來自微信支付服務(wù)器,避免中間人攻擊。而驗(yàn)證響應(yīng)簽名除了需要微信平臺(tái)的公鑰外還需要從請(qǐng)求頭的其它參數(shù)。
假設(shè)以下就是微信支付服務(wù)器的響應(yīng):
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 02 Apr 2019 12:59:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2204
Connection: keep-alive
Keep-Alive: timeout=8
Content-Language: zh-CN
Request-ID: e2762b10-b6b9-5108-a42c-16fe2422fc8a
Wechatpay-Nonce: c5ac7061fccab6bf3e254dcf98995b8c
Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==
Wechatpay-Timestamp: 1554209980
Wechatpay-Serial: 5157F09EFDC096DE15EBE81A47057A7232F1B8E1
Cache-Control: no-cache, must-revalidate
{"prepay_id":"wx2922034726858082fbd40b511c67630000"}
微信支付響應(yīng)的時(shí)候會(huì)攜帶一個(gè)微信平臺(tái)證書序列號(hào),從響應(yīng)頭中的Wechatpay-Serial
字段中獲取值,用來提示我們要使用該序列號(hào)的證書來進(jìn)行驗(yàn)簽,如果不存在就需要我們刷新證書,而上一文我們將平臺(tái)證書序列號(hào)和證書以鍵值對(duì)存在HashMap
中,我們只需要檢查是否存在即可,不存在就刷新。
從響應(yīng)結(jié)果中獲取對(duì)應(yīng)下面方法的三個(gè)參數(shù)就可以構(gòu)造出驗(yàn)簽名串。
/**
* 構(gòu)造驗(yàn)簽名串.
*
* @param wechatpayTimestamp HTTP頭 Wechatpay-Timestamp 中的應(yīng)答時(shí)間戳。
* @param wechatpayNonce HTTP頭 Wechatpay-Nonce 中的應(yīng)答隨機(jī)串
* @param body 響應(yīng)體
* @return the string
*/
public String responseSign(String wechatpayTimestamp, String wechatpayNonce, String body) {
return Stream.of(wechatpayTimestamp, wechatpayNonce, body)
.collect(Collectors.joining("\n", "", "\n"));
}
待驗(yàn)證的簽名從響應(yīng)頭中的Wechatpay-Signature
字段中獲取,我們使用微信支付平臺(tái)公鑰對(duì)驗(yàn)簽名串和簽名進(jìn)行SHA256 with RSA簽名驗(yàn)證。
// 構(gòu)造驗(yàn)簽名串
final String signatureStr = responseSign(wechatpayTimestamp, wechatpayNonce, body);
// 加載SHA256withRSA簽名器
Signature signer = Signature.getInstance("SHA256withRSA");
// 用微信平臺(tái)公鑰對(duì)簽名器進(jìn)行初始化
signer.initVerify(certificate);
// 把我們構(gòu)造的驗(yàn)簽名串更新到簽名器中
signer.update(signatureStr.getBytes(StandardCharsets.UTF_8));
// 把請(qǐng)求頭中微信服務(wù)器返回的簽名用Base64解碼 并使用簽名器進(jìn)行驗(yàn)證
boolean result = signer.verify(Base64Utils.decodeFromString(wechatpaySignature));
/**
* 我方對(duì)響應(yīng)驗(yàn)簽,和應(yīng)答簽名做比較,使用微信平臺(tái)證書.
*
* @param wechatpaySerial response.headers['Wechatpay-Serial'] 當(dāng)前使用的微信平臺(tái)證書序列號(hào)
* @param wechatpaySignature response.headers['Wechatpay-Signature'] 微信平臺(tái)簽名
* @param wechatpayTimestamp response.headers['Wechatpay-Timestamp'] 微信服務(wù)器的時(shí)間戳
* @param wechatpayNonce response.headers['Wechatpay-Nonce'] 微信服務(wù)器提供的隨機(jī)串
* @param body response.body 微信服務(wù)器的響應(yīng)體
* @return the boolean
*/
@SneakyThrows
public boolean responseSignVerify(String wechatpaySerial, String wechatpaySignature, String wechatpayTimestamp, String wechatpayNonce, String body) {
if (CERTIFICATE_MAP.isEmpty() || !CERTIFICATE_MAP.containsKey(wechatpaySerial)) {
refreshCertificate();
}
Certificate certificate = CERTIFICATE_MAP.get(wechatpaySerial);
final String signatureStr = createSign(wechatpayTimestamp, wechatpayNonce, body);
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initVerify(certificate);
signer.update(signatureStr.getBytes(StandardCharsets.UTF_8));
return signer.verify(Base64Utils.decodeFromString(wechatpaySignature));
}
CERTIFICATE_MAP
平臺(tái)證書容器可參考上一篇文章。
驗(yàn)簽通過就說明我們請(qǐng)求的響應(yīng)來自微信服務(wù)器就可以針對(duì)結(jié)果進(jìn)行對(duì)應(yīng)的邏輯處理了,微信支付 API 無論是 V2 還是 V3 都包含了使用Api 證書對(duì)請(qǐng)求進(jìn)行加簽,對(duì)響應(yīng)結(jié)果進(jìn)行驗(yàn)簽的流程,十分考驗(yàn)對(duì)密碼摘要算法的使用,其它無非就是組織參數(shù)調(diào)用 Http 請(qǐng)求。如果你能夠掌握這一能力就會(huì)在面試中和工作中占到優(yōu)勢。
文章轉(zhuǎn)自微信公眾號(hào)@碼農(nóng)小胖哥
node.js + express + docker + mysql + jwt 實(shí)現(xiàn)用戶管理restful api
nodejs + mongodb 編寫 restful 風(fēng)格博客 api
表格插件wpDataTables-將 WordPress 表與 Google Sheets API 連接
手把手教你用Python和Flask創(chuàng)建REST API
使用 Django 和 Django REST 框架構(gòu)建 RESTful API:實(shí)現(xiàn) CRUD 操作
ASP.NET Web API快速入門介紹
2024年在線市場平臺(tái)的11大最佳支付解決方案
完整指南:如何在應(yīng)用程序中集成和使用ChatGPT API
選擇AI API的指南:ChatGPT、Gemini或Claude,哪一個(gè)最適合你?
對(duì)比大模型API的內(nèi)容創(chuàng)意新穎性、情感共鳴力、商業(yè)轉(zhuǎn)化潛力
一鍵對(duì)比試用API 限時(shí)免費(fèi)