微信支付回调V3版,md笔记


微信支付回调V3版

简要逻辑

主要大体分为两步,验签和解密回调内容。必要条件是申请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校验啥的,什么限制都不要加。

回调接口在文档里对应的就是支付通知,且不能带任何参数。

 


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM