簡要邏輯
主要大體分為兩步,驗簽和解密回調內容。必要條件是申請V3密鑰(在官方設置的32位的密鑰,並不是一個文件), 申請平台證書(注意不是商戶證書)。
1 驗簽
驗簽的目的是為了確定回調請求來自於微信官方,而非其他第三方。
2 解密
解密是解密出微信官方回調后resource字段里的 ciphertext 字段。從而實現本身業務
詳細流程
微信支付回調是在於支付下單接口后,微信官方跟你你下支付下單接口中的 notify_url (通知地址)字段,進行調用。首先是統一下單接口完成之后,在你支付過后微信會自動帶上你生成訂單時的商戶信息,然后去訪問你的接口,請注意,一定要是https的接口。如果本地測試則需要去做內網穿透,並且配置DNS等等一系列操作來申請到https的域名。
第一步,我們先拿到微信請求中我們能用到的請求頭的參數
String serialNo = request.getHeader("Wechatpay-Serial");// 商戶序列號
String nonce = request.getHeader("Wechatpay-Nonce");// 隨機字符串
String timestamp = request.getHeader("Wechatpay-Timestamp"); // 時間戳
String wechatpaySignature = request.getHeader("Wechatpay-Signature"); // 簽名
請注意,這四個參數都是回調時從請求頭里拿到的,然后我們需要拿到完整的請求頭,這里建議用下面代碼實現
/**
* 獲取微信請求頭
*
* @param request
* @return
* @throws IOException
*/
public static String getRequestBody(HttpServletRequest request) throws IOException {
ServletInputStream stream = null;
BufferedReader reader = null;
StringBuffer sb = new StringBuffer();
try {
stream = request.getInputStream();
// 獲取響應
reader = new BufferedReader(new InputStreamReader(stream));
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
throw new IOException("讀取返回支付接口數據流出現異常!");
} finally {
reader.close();
}
return sb.toString();
}
上面我們實現的是拿到官方回調的參數,現在我們需要拿平台證書的一系列信息。請注意是平台證書,平台證書只能通過API接口實現
微信支付官方平台證書獲取:https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay5_1.shtml
或者去微信支付官方用工具實現(這里我嘗試很多遍一直失敗,找不到原因)。最終我的選擇是用官網API實現,代碼如下
//拿到簽名
String sign = getToken("GET", certificatesUrl, null, xjlMchId, xjlSerialNo, v3keyPath);
HttpGet httpPost = new HttpGet(certificatesUrl);
//設置頭
httpPost.setHeader("Authorization", "WECHATPAY2-SHA256-RSA2048" + " " + sign);
httpPost.setHeader("Accept", "*/*");
httpPost.setHeader("User-Agent", "*/*");
CloseableHttpClient httpClient = HttpClients.createDefault();
//完成簽名並執行請求
CloseableHttpResponse resp = httpClient.execute(httpPost);
JSONObject map = JSON.parseObject(EntityUtils.toString(resp.getEntity()));
JSONArray data = map.getJSONArray("data");
這里如何獲取簽名就不多做贅述了 certificatesUrl 是平台證書請求路徑,null是請求體body,這里設null,xjlMchId,xjlSerialNo,v3keyPath 這三個分別是你商戶的商戶號,商戶序列號(這個序列號是寫死的,登錄商戶平台能看到),與密鑰的路徑(第一次申請的時候會下載,注意做保存)。最后發送GET請求后拿到data數據,就是我們平台證書的結構啦。具體結構微信官網文檔里都有,就不多贅述了。
拿到證書之后 我們要做的當然就是解密證書啦,說到解密證書就不得不提一下我們開頭說到V3版的密鑰了,這里 就需要用到,為了方便理解還是給大家截個圖,下面就是證書的結構了,我們解析出ciphertext字段就可以了
我們需要拿到 serial_no ,associated_data,nonce,ciphertext等字段,外加V3版密鑰。拿到這些參數后調用下面方法就可以拿到解密后的平台證書了
public static 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);
}
}
解密出來之后,就是一串公鑰,然后我們將公鑰轉成Certificate文件,存入map里。作為全局變量即可。代碼如下,這里我們拿到的序列號就作為平台證書的key,這樣就可以很好的區別每個證書了
String publicKey = decryptResponseBody(v3Key, 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();
// 放入證書
CERTIFICATE_MAP.put(serialNo, certificate);
到這里我們就有了平台證書的所有信息,然后我們只需要將微信回調頭的重要信息拿到生成簽名用SHA256withRSA算法去比對驗簽,這個驗簽的操作就完成了。
然后就說說生成簽名頭的操作,前面提到的,我們只需要拿到Wechatpay-Nonce,Wechatpay-Timestamp信息,然后調用getRequestBody方法拿到請求頭,三個參數就可以完成簽名操作了。代碼如下
/**
* 回調驗簽-構建簽名數據
* @param
* @return
*/
public static String buildMessage(String wechatpayTimestamp, String wechatpayNonce, String body) {
return Stream.of(wechatpayTimestamp, wechatpayNonce, body)
.collect(Collectors.joining("\n", "", "\n"));
}
獲取到簽名后就可以取我們存平台證書的Map了,這里根據我前面提到的序列號來取。如果為空就需要再去調用一遍獲取平台證書的接口,代碼如下
/**
* 回調驗簽
* https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_1.shtml
* @param wechatpaySignature 回調head頭部
* @param wechatpayTimestamp 回調head頭部
* @param wechatpayNonce 回調head頭部
* @param body 請求數據
* @return
*/
public static boolean responseSignVerify( String wechatpaySignature, String wechatpayTimestamp, String wechatpayNonce, String body,String serialNo,String xjlMchId,String xjlSerialNo,String v3keyPath,String v3Key) {
FileInputStream fileInputStream = null;
try {
//獲取簽名
String signatureStr = buildMessage(wechatpayTimestamp, wechatpayNonce, body);
Signature signer = Signature.getInstance("SHA256withRSA");
if (CERTIFICATE_MAP.isEmpty() || !CERTIFICATE_MAP.containsKey(serialNo)) {
//獲取證書
certificates(xjlMchId,xjlSerialNo,v3keyPath,v3Key);
}
signer.initVerify(CERTIFICATE_MAP.get(serialNo));
signer.update(signatureStr.getBytes(StandardCharsets.UTF_8));
return signer.verify(java.util.Base64.getDecoder().decode(wechatpaySignature));
} catch (Exception e ) {
e.printStackTrace();
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return false;
}
到這里,驗簽操作就全部做完了,如果驗簽失敗,就要考慮多個問題了,比如平台證書是否過期,v3密鑰是否正確, 等等。也有可能是其他惡意攻擊的人訪問了你的接口。
然后驗簽通過后就可以開始解密了。解密操作就太簡單了,只需要把微信官方做解密的類 AesUtil 直接拿過來用就行了,好人做到底,我還是粘貼一下,順便打點備注,微信官方文檔是真的啥也不提
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AesUtil {
static final int KEY_LENGTH_BYTE = 32;
static final int TAG_LENGTH_BIT = 128;
private final byte[] aesKey;//這里是V3密鑰,自己設置的
public AesUtil(byte[] key) {
if (key.length != KEY_LENGTH_BYTE) {
throw new IllegalArgumentException("無效的ApiV3Key,長度必須為32個字節");
}
this.aesKey = key;
}
public String decryptToString(byte[] associatedData, byte[] nonce, String ciphertext)
throws GeneralSecurityException, IOException {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec key = new SecretKeySpec(aesKey, "AES");
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, nonce);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
cipher.updateAAD(associatedData);
return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), "utf-8");
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException(e);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalArgumentException(e);
}
}
}
最后一步,直接上代碼。
//驗簽 boolean flag = V3Util.responseSignVerify( wechatpaySignature, timestamp, nonce, requestBody,serialNo,xjlMchId,xjlSerialNo,xjlV3keyPath,v3Key); //驗簽成功 if(flag){ //拿到請求頭里的resource部分 JSONObject resources = JSONObject.parseObject(requestBody).getJSONObject("resource"); System.out.println("resource:"+resources); //用32位的v3密鑰做個構造 AESUtil aesUtil = new AESUtil(key.getBytes(StandardCharsets.UTF_8)); //取出resource下 associated_data nonce參數,再;配上v3key 用作解密ciphertext byte[] associatedDataByte =resources.getString("associated_data").getBytes(StandardCharsets.UTF_8); byte[] nonceByte = resources.getString("nonce").getBytes(StandardCharsets.UTF_8); String ciphertext = resources.getString("ciphertext"); //解密 String res = aesUtil.decryptToString(associatedDataByte,nonceByte,ciphertext); JSONObject jsonObject = JSONObject.parseObject(res); System.err.println("回調結果:"+jsonObject);
注意事項
平台證書只能通過API拿,跟商戶證書不一樣。
V3key需要自己申請,手打32位的字符串。不是一個文件
回調的接口一定要保證自己服務器域名支持https的,不然官方請求進不來
回調接口一定不要加限制,不要加攔截器,token校驗啥的,什么限制都不要加。
回調接口在文檔里對應的就是支付通知,且不能帶任何參數。