本文包括P12证书文件反解私钥、公钥、微信支付APIV3版的获取平台证书接口,加解密方法, jsapi支付接口等。
微信支付更新了接口规则,将以往的xml数据格式改为json,增加了一系列签名验签证书等,但是官方文档并不完善且问题较杂较多,罗列一下自己的解决办法。
这里是微信支付的开发者文档:
微信支付-开发者文档https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_1.shtml
对于微信接口的流程图、时序图这里就不赘述了,相信在使用接口的过程中,按照目前文档的粒度,是必然会出现问题的,这里直接上解决问题的办法。
一、前期准备
登录商户微信号,在API安全中,你可以获取到如下:
商户号、appid、证书、API密钥、APIV3密钥
※若非新申请证书,可能会出现只有p12文件,无原始证书,这里需要根据p12文件反解出之前的证书,但是需要证书的口令,没有口令则没有任何办法,方法如下:
1、windows下可以直接双击p12证书文件,在导入过程会提醒输入密码,此时方便你测试你的口令。
2、提取
打开cmd,利用OpenSSL命令提取RSA原始密钥对。
openssl pkcs12 -in 你的证书.p12 -nocerts -nodes -out rsa_origin.key
密钥内容一般由-----BEGIN PRIVATE KEY-----开始,以-----END PRIVATE KEY-----结束,类似于:
此时为原始密钥,还不能直接使用,一般来说,P12证书->口令解密->原始密钥->公钥、私钥。
提取私钥:
openssl rsa -in rsa_origin.key -out rsa_pkcs1.pem
此时获取的密钥为PKCS#1格式,PHP可直接使用,我们java需要再次转换:
openssl pkcs8 -topk8 -inform PEM -in rsa_pkcs1.pem -outform PEM -out rsa_private_pkcs8.pem -nocrypt
提取公钥
openssl rsa -in rsa_origin.key -pubout -out rsa_public_key.pem
至此,通过P12证书,可以获取到三个文件,分别是原始密钥rsa_origin.key,1格式私钥rsa_pkcs1.pem,8格式私钥rsa_private_pkcs8.pem,和公钥rsa_public_key.pem。
到这里,微信支付的商户证书环节就够了,API密钥和APIV3密钥是字符串,也是设置的时候才能保存,微信不提供获取。
现在,你拥有:API密钥、APIV3密钥、私钥文件rsa_private_pkcs8.pem(文件打开也能拿到私钥内容)、商户ID、APPID、APPsecret、证书序列号。
二、正题
在官方提供的SDK中,提供了一个封装的httpclient,使用这个client进行接口的调用。
在你以为上面的准备已经足够了时候,微信支付一定会给你一个惊喜。
熟读简单支付JSAPI下单接口文档后,发现openid这个参数也需要我们自己获取,这个接口并没有验签签名,相对来说简单很多。
直接贴代码
log.info("getUserInfo user is :{} , code:{}", u, code);
String url = "https://api.weixin.qq.com/sns/jscode2session";
url += "?appid=appid";//自己的appid
url += "&secret=secret";//自己的appSecret
url += "&js_code=" + code;
url += "&grant_type=authorization_code";
String res = null;
CloseableHttpClient httpClient = HttpClientBuilder.create().build();
// DefaultHttpClient();
HttpGet httpget = new HttpGet(url); //GET方式
CloseableHttpResponse response = null;
// 配置信息
RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(5000).setConnectionRequestTimeout(5000).setSocketTimeout(5000).setRedirectsEnabled(false).build();
httpget.setConfig(requestConfig);
response = httpClient.execute(httpget);
HttpEntity responseEntity = response.getEntity();
log.info("响应状态为:{}", response.getStatusLine());
if (responseEntity != null) {
res = EntityUtils.toString(responseEntity);
log.info("响应内容长度为:{}", responseEntity.getContentLength());
log.info("响应内容为:{}", res);
}
// 释放资源
if (httpClient != null) {
httpClient.close();
}
if (response != null) {
response.close();
}
JSONObject jo = JSON.parseObject(res);
String errcode = jo.getString("errcode");
if (!StringUtils.isEmpty(errcode) && !errcode.equals("0")) {
return Result.error(res);
}
String openid = jo.getString("openid");
这就获取到了openId,珍惜为数不多的简单接口。
再次熟读JSAPI下单接口接口,发现参数我们都有了,下面调用即可,上面说了,官方已经提供了SDK,但是在我们悉心挖掘后发现,SDK只提供了一个client,所以我们使用这个client进行接口的调用即可,嗯,听起来简单的很。
这里官方SDK:GitHub - wechatpay-apiv3/wechatpay-apache-httpclient: 微信支付 APIv3 Apache HttpClient装饰器(decorator)
这里是依赖:
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.3.0</version>
</dependency>
这里是SDK的构建client示例
明确概念很重要,这里的商户号、证书序列号都可以通过商户微信号在API安全里面获取到,这里的商户API私钥,就是刚才我们反解出来的 私钥文件rsa_private_pkcs8.pem(文件打开也能拿到私钥内容)
这里的 微信支付平台证书 ,没错,你现在没有,这个不是上面的任何一个证书,所以接下来,我们要继续获取证书。
参考官方文档:微信支付-开发者文档https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay5_1.shtml
这里就是获取证书的接口,接口很简单,不需要参数,但是需要header中放入签名Authorization,所以这个接口的坑大部分都是构建Authorization和解密返回结果,这里容我不想废话,直接上代码
获取证书列表
private JSONObject getCert() throws Exception {
//header的值
Map headers = new HashMap(1);
HttpUrl httpurl = HttpUrl.parse("https://api.mch.weixin.qq.com/v3/certificates");
String t = getToken("GET", httpurl, "");
headers.put("Authorization", "WECHATPAY2-SHA256-RSA2048 " + t);
//加入自己的url
StringBuffer url = new StringBuffer("https://api.mch.weixin.qq.com/v3/certificates");
//参数封组装
RestTemplate restTemplate = new RestTemplate();
//组装请求头
HttpHeaders requestHeaders = this.buildHeaders(headers);
HttpEntity<String> requestEntity = new HttpEntity(requestHeaders);
ResponseEntity<String> response = restTemplate.exchange(url.toString(),
HttpMethod.GET, requestEntity, String.class, new Object[0]);
JSONObject result = JSONObject.parseObject(response.getBody(), JSONObject.class);
return result;
}
构建签名串
String getToken(String method, HttpUrl url, String body) throws Exception {
String nonceStr = "nonceStr";
long timestamp = System.currentTimeMillis() / 1000;
String message = buildMessage(method, url, timestamp, nonceStr, body);
String signature = sign(message.getBytes("utf-8"));
return "mchid=\"" + "mchid" + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + "serial_no" + "\","
+ "signature=\"" + signature + "\"";
}
构建签名前数据
String buildMessage(String method, HttpUrl url, long timestamp, String nonceStr, String body) {
String canonicalUrl = url.encodedPath();
if (url.encodedQuery() != null) {
canonicalUrl += "?" + url.encodedQuery();
}
return method + "\n"
+ canonicalUrl + "\n"
+ timestamp + "\n"
+ nonceStr + "\n"
+ body + "\n";
}
构建header
protected HttpHeaders buildHeaders(Map<String, String> headers) {
HttpHeaders reqHeaders = new HttpHeaders();
if (null != headers) {
for (String key : headers.keySet()) {
if (null == headers.get(key)) {
reqHeaders.add(key, "");
}
reqHeaders.add(key, headers.get(key));
}
}
return reqHeaders;
}
签名
String sign(byte[] message) throws Exception {
Signature sign = Signature.getInstance("SHA256withRSA");
String s = "-----BEGIN PRIVATE KEY-----" +
"-----END PRIVATE KEY-----";
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(s);
sign.initSign(merchantPrivateKey);
sign.update(message);
return java.util.Base64.getEncoder().encodeToString(sign.sign());
}
注意,PemUtil.loadPricateKey函数,有两个重载方法,string入参的时候,入参为密钥里面的内容,InputStream为入参时,直接getClass().getResourceAsStream("/pay/path")也可以。
这个密钥,就是上面,私钥文件rsa_private_pkcs8.pem(文件打开也能拿到私钥内容)
获取参数
private String dec(JSONObject res) {
log.info("===================:{}", res);
CertVo sign = JSON.parseObject(res.toJSONString(), new TypeReference<CertVo>() {});
List<CertVo.DataBean> data = sign.getData();
String s = "";
for (int i = 0; i < data.size(); i++) {
CertVo.DataBean dataBean = data.get(i);
CertVo.DataBean.EncryptCertificateBean encrypt_certificate = dataBean.getEncrypt_certificate();
String associated_data = encrypt_certificate.getAssociated_data();
String ciphertext = encrypt_certificate.getCiphertext();
String nonce = encrypt_certificate.getNonce();
String apiv3 = "apiv3";
s = decryptResponseBody1(apiv3, associated_data, nonce, ciphertext);
}
return s;
}
解密
public String decryptResponseBody1(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);
}
}
实体类
@Data
public class CertVo implements Serializable {
private List<DataBean> data;
@Data
public static class DataBean implements Serializable {
/**
* effective_time : 2021-03-11T13:55:55+08:00
* encrypt_certificate : {"associated_data":"certificate","ciphertext":"DG","nonce":"nonce","algorithm":"AEAD_AES_256_GCM"}
* expire_time : 2026-03-10T13:55:55+08:00
* serial_no : serial_no
*/
private String effective_time;
private EncryptCertificateBean encrypt_certificate;
private String expire_time;
private String serial_no;
@Data
public static class EncryptCertificateBean implements Serializable {
/**
* associated_data : certificate
* ciphertext : DG
* nonce : nonce
* algorithm : algorithm
*/
private String associated_data;
private String ciphertext;
private String nonce;
private String algorithm;
}
}
}
由于参数等原因,上述代码是简版,调通借口后细节配置等还需要完善。
至此,你获取到了一个平台证书的解密后的字符串类型,即dec函数的返回值。
获取证书
CertificateFactory cf = CertificateFactory.getInstance("X509");
ByteArrayInputStream inputStream = new ByteArrayInputStream(dec.getBytes(StandardCharsets.UTF_8));
Certificate certificate = null;
try {
certificate = cf.generateCertificate(inputStream);
} catch (CertificateException e) {
e.printStackTrace();
}
OK,接下来构建client,直接上
构建client
public HttpClient initWChant() throws Exception {
String s = "-----BEGIN PRIVATE KEY-----" +
"-----END PRIVATE KEY-----";
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(s);
JSONObject cert = getCert();
String dec = dec(cert);
CertificateFactory cf = CertificateFactory.getInstance("X509");
ByteArrayInputStream inputStream = new ByteArrayInputStream(dec.getBytes(StandardCharsets.UTF_8));
Certificate certificate = null;
try {
certificate = cf.generateCertificate(inputStream);
} catch (CertificateException e) {
e.printStackTrace();
}
List<X509Certificate> a = new ArrayList<>();
a.add((X509Certificate) certificate);
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant("merchantId","merchantSerialNumber", merchantPrivateKey)
.withWechatPay(a);
return builder.build();
}
注意,PemUtil.loadPricateKey函数,有两个重载方法,string入参的时候,入参为密钥里面的内容,InputStream为入参时,直接getClass().getResourceAsStream("/pay/path")也可以。
至此,就拿到了client,官方也说会不定时更新他们的支付平台证书,这点在实战是需要考虑在内,进行设计。
在此过程,会出现n多bug,其中最难解决的是使用jdk8 151以下版本时,解密时会出现
java.security.InvalidKeyException: Illegal key size
大部分同行会告诉你换文件,随便能搜到,就不贴了,这里我推荐直接更换jdk,换个高版本的jdk会更快解决这个问题。各种方法各有利弊。
接下来就是获取prepay_id的,直接上
HttpPost httpPost = new HttpPost(VX_APP_PAY_URL);
httpPost.addHeader("Accept", "application/json");
httpPost.addHeader("Content-type", "application/json; charset=utf-8");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode rootNode = objectMapper.createObjectNode();
//商户id
rootNode.put("mchid", "mchid")
//小程序id
.put("appid", "mchid")
//描述
.put("description", "description")
//微信通知回调地址
.put("notify_url", "")
//商户订单id
.put("out_trade_no", tOrder.getOrderNum());
BigDecimal bigDecimal = payVo.getMoney().movePointRight(2);
rootNode.putObject("amount").put("total", bigDecimal.intValue());
rootNode.putObject("payer").put("openid", openId);
log.info("===============:{}",rootNode);
objectMapper.writeValue(bos, rootNode);
httpPost.setEntity(new StringEntity(bos.toString("UTF-8")));
HttpClient httpClient = initWChant();
HttpResponse response = httpClient.execute(httpPost);
String bodyAsString = EntityUtils.toString(response.getEntity());
你可以使用各种json工具,取出你要的prepay_id。
本以为到此结束,但是发现jsapi调起支付接口中,也涉及到了签名作为参数,不得不感叹,微信你是真滴安全,前端表示可不可以后端一起返回,后端表示可以,直接上:
long timeStamp = System.currentTimeMillis() / 1000;
String nonceStr = WXPayUtil.generateNonceStr();
String dataStr = "appId" + "\n" + timeStamp + "\n" + nonceStr + "\n" + "prepay_id=" + prepay_id.toString() + "\n";
//要加密的数据
System.out.println("要加密的数据:" + dataStr);
String signature = sign(dataStr.getBytes("utf-8"));
Map<String, String> payMap = new HashMap<>();
payMap.put("appId", "wx7c97a277f6ba30b4");
payMap.put("timeStamp", timeStamp + "");
payMap.put("nonceStr", nonceStr);
payMap.put("signType", "RSA");
payMap.put("package", "prepay_id=" + prepay_id);
payMap.put("paySign", signature);
对,没错,加密方法就是上面的加密方法,其实很简单,所以才会表示可以。
到这里,对接微信支付PAIV3版接口的,支付下单接口就完成了,后面的回调验签解密,直接调用上面的方法就可以,结合业务逻辑操作即可。
不得不说,在微信开放社区那么多人提问的前提下,还是需要一步一步摸索,才搞清楚整体流程需要的东西,搞通这一个下单支付接口还花费了两天时间,实在是感叹,微信支付APIV3,你是真滴安全。