简要逻辑
主要大体分为两步,验签和解密回调内容。必要条件是申请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校验啥的,什么限制都不要加。
回调接口在文档里对应的就是支付通知,且不能带任何参数。