微信支付回調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