<返回更多

Springboot使用OkHttp实现微信支付API-V3签名、证书的管理和使用

2021-12-29    666Tec
加入收藏

微信支付API-V3和V2的区别

微信支付API-V3和之前V2版本最大的区别,应该就是加密方式的改变了。新版的支付接口,全部使用是SSL双向加密。就是指微信服务器端、商户端各自都有一套证书,两者之间通讯必需使用自己证书的私钥加密,使用对方的公钥解密。具体流程图,可以参考微信支付官网的这张图:

Springboot使用OkHttp实现微信支付API-V3签名、证书的管理和使用

 

上图所在的文档链接是:
https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay3_0.shtml 有需要的可参考。

由于微信支付官方提供的JAVA Demo使用的是Httpclient,并且比较庞杂。所以我自己对接的时候,是使用OkHttp完成的。

接入前准备

每种支付方式的准备都稍有区别,但区别也不大。这里以JS-API支付为例,简单说说一个全新的微信支付账号,要做哪些配置。

  1. 绑定App ID和商户号mch id:微信支付申请下来后,是没办法单独使用的。肯定要依托于公众号、小程序、APP或者网站等载体,这些载体都有自己的APP ID,需要在这些载体对应的后台里,找到微信支付菜单,点进去把微信支付的mch id和app id绑定起来。
  2. 设置API KEY:这个key主要用来解密一些微信接口的返回结果,比如下载微信平台证书的时候。(为什么在双向证书的情况下,还需要这个key呢?因为这些证书,只使用来签名的,并不能加密每次请求的body)
  3. 下载商户证书:这个没啥好说的,新版的微信支付V3接口,商户和微信平台,各自都有证书。
  4. 配置各种授权域名、授权目录等。

以上就是接入前准备的简单介绍,具体每一步的详细操作,可以参考微信支付官方的文档:

使用OkHttp封装自带微信支付API-V3证书加解密、签名的请求

微信支付本身对接起来不麻烦,无非就是下单、支付、等通知该状态三步。对新手不友好的地方,主要还是各种加密、签名等安全措施。接下来我介绍一下如何使用OkHttp封装一个http请求类,包含各种安全验证措施,外部调用的时候,只要当普通OkHttp接口调用就行。各种权限验证,已经在类里面自己实现了。

简单介绍一下封装类的各个方法:

完整代码如下,代码依赖了很多常见的类库,比如Apache-commons-lang3等,可以从代码的import中看出来,自行添加maven pom。

package com.coderbbb.blogv2.utils;

import com.coderbbb.blogv2.database.dos.WxPlatCertDO;
import com.coderbbb.blogv2.database.dto.WxCertDataDTO;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class WxOkHttpUtil extends OkhttpUtil {

    private static final String TOKEN_PATTERN = "WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"";

    private static final String CERT_LIST = "https://api.mch.weixin.qq.com/v3/certificates";

    public static ConcurrentHashMap<String, WxPlatCertDO> WX_PLAT_CERT = new ConcurrentHashMap<>();

    private final static Logger logger = LoggerFactory.getLogger(WxOkHttpUtil.class);

    /**
     * 生成请求微信接口时需要的Authorization头
     * @param url
     * @param method
     * @param json
     * @return
     */
    public static String generateToken(String url, String method, String json) {
        if (json == null) {
            json = "";
        }
        url = url.substring(StringUtils.ordinalIndexOf(url, "/", 3));

        long timestamp = System.currentTimeMillis() / 1000;
        String timestampStr = String.valueOf(timestamp);
        String nonceStr = RandomStringUtils.random(16, true, true);

        String signatureStr = Stream.of(method.toUpperCase(Locale.ROOT), url, timestampStr, nonceStr, json).collect(Collectors.joining("n", "", "n"));

        WxCertDataDTO wxCertDataDTO = WxCertUtil.getCert();


        String signResult;
        try {
            Signature sign = Signature.getInstance("SHA256withRSA");
            sign.initSign(wxCertDataDTO.getPrivateKey());
            sign.update(signatureStr.getBytes(StandardCharsets.UTF_8));
            signResult = Base64.encodeBase64String(sign.sign());
        } catch (Exception e) {
            throw new RuntimeException("签名失败", e);
        }


        //开始拼接Token
        return String.format(TOKEN_PATTERN, wxCertDataDTO.getMchId(), nonceStr, timestamp, wxCertDataDTO.getSerialNumber(), signResult);
    }

    /**
     * 请求基本Header头,微信规定的。
     * 文档地址:https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay2_0.shtml
     * @param headerMap
     * @return
     */
    private static HashMap<String, String> intHeader(HashMap<String, String> headerMap) {
        if (headerMap == null) {
            headerMap = new HashMap<>();
        }
        headerMap.put("content-type", "application/json;charset=UTF-8");
        headerMap.put("user-agent", "coderbbb");
        headerMap.put("accept", "application/json");
        return headerMap;
    }

    /**
     * 微信请求我们时(比如支付的异步通知),拿这个函数校验微信的请求是否是合法的
     * 简单说,就是验证微信请求我们时,签名是否正确的。
     * @param request
     * @param requestBody
     * @return
     */
    public static boolean checkServletRequestSign(HttpServletRequest request, String requestBody) {

        String wxCertSerialNumber = request.getHeader("Wechatpay-Serial");
        String timestamp = request.getHeader("Wechatpay-Timestamp");
        String nonce = request.getHeader("Wechatpay-Nonce");
        String sign = request.getHeader("Wechatpay-Signature");

        if (!WX_PLAT_CERT.containsKey(wxCertSerialNumber)) {
            return false;
        }

        WxPlatCertDO wxPlatCertDO = WX_PLAT_CERT.get(wxCertSerialNumber);

        String signBody = Stream.of(timestamp, nonce, requestBody).collect(Collectors.joining("n", "", "n"));

        //开始验签
        try {
            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initVerify(wxPlatCertDO.getCertificate());
            signature.update(signBody.getBytes(StandardCharsets.UTF_8));
            if (!signature.verify(Base64.decodeBase64(sign))) {
                return false;
            }
        } catch (Exception e) {
            logger.error("验签错误", e);
            return false;
        }
        return true;
    }

    /**
     * 我们请求微信的接口后,得到返回结果,用这个函数把返回结果生成签名,和返回的签名对比是否一致
     * 简单说,就是请求微信的接口后,我们自己用返回结果生成一个签名,和请求返回的签名对比,看看签名一样不
     * @param response
     * @param lazyVerify
     * @return
     */
    private static String checkResponseSign(Response response, boolean lazyVerify) {
        String result = null;
        String wxCertSerialNumber;
        String timestamp;
        String nonce;
        String sign;

        try (ResponseBody body = response.body();) {
            if (body != null) {
                result = body.string();
            }

            if (response.code() != 200) {
                logger.warn("err response = " + result);
                throw new RuntimeException("请求http code异常:" + response.code());
            }

            wxCertSerialNumber = response.header("Wechatpay-Serial");
            timestamp = response.header("Wechatpay-Timestamp");
            nonce = response.header("Wechatpay-Nonce");
            sign = response.header("Wechatpay-Signature");
        } catch (IOException e) {
            throw new RuntimeException("wx okHttp read response err", e);
        }

        WxPlatCertDO wxPlatCertDO = null;
        if (!WX_PLAT_CERT.containsKey(wxCertSerialNumber)) {
            //平台证书不在已有的列表内
            /**
             * 我们提供以下的机制,帮助商户在平台证书更新时实现平滑切换:
             *
             * 1.下载新平台证书。我们将在旧证书过期前10天生成新证书。
             * 商户可使用平台证书下载API 下载新平台证书,并在旧证书过期前5-10天部署新证书。
             *
             * 2.兼容使用新旧平台证书。旧证书过期前5天至过期当天,新证书开始逐步放量用于应答和回调的签名。
             * 商户需根据证书序列号,使用对应版本的平台证书。
             * (我们在所有API应答和回调的HTTP头部Wechatpay-Serial,声明了此次签名所对应的平台证书的序列号。)
             */
            //所以:定时任务拉取微信平台证书,在这里,如果证书不在列表内,只有两种情况:
            //1.该请求是第一次下载微信平台证书的请求;2.恶意请求。
            // - 我们使用lazyVerify标记第一种情况
            if (!lazyVerify) {
                //不能延迟验签,抛出错误
                throw new RuntimeException("签名校验失败");
            } else {
                //可以延迟验签,说明该请求是下载微信平台证书的请求,直接读取请求返回值,提取证书
                List<WxPlatCertDO> wxPlatCertData = decodeWxPlatCert(result);
                for (WxPlatCertDO item : wxPlatCertData) {
                    if (item.getSerialNumber().equals(wxCertSerialNumber)) {
                        wxPlatCertDO = item;
                    }
                }
            }
        } else {
            wxPlatCertDO = WX_PLAT_CERT.get(wxCertSerialNumber);
        }

        if (wxPlatCertDO == null) {
            throw new RuntimeException("平台证书不存在,验签失败");
        }

        String signBody = Stream.of(timestamp, nonce, result).collect(Collectors.joining("n", "", "n"));

        //开始验签
        try {
            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initVerify(wxPlatCertDO.getCertificate());
            signature.update(signBody.getBytes(StandardCharsets.UTF_8));
            if (!signature.verify(Base64.decodeBase64(sign))) {
                throw new RuntimeException("签名错误,请求不安全");
            }
        } catch (Exception e) {
            throw new RuntimeException("验签错误", e);
        }

        return result;
    }

    /**
     * 下载微信平台证书时,用这个函数解密拿到的返回值,得到微信平台证书列表
     * @param json
     * @return
     */
    private static List<WxPlatCertDO> decodeWxPlatCert(String json) {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode jsonNode;
        JsonNode dataNode;
        try {
            jsonNode = mapper.readTree(json);
            dataNode = jsonNode.get("data");
        } catch (Exception e) {
            throw new RuntimeException("读取证书JSON失败", e);
        }

        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");

        List<WxPlatCertDO> certList = new ArrayList<>();
        for (JsonNode itemNode : dataNode) {
            JsonNode certNode = itemNode.get("encrypt_certificate");
            String cert = AesUtil.decryptJsonNodeToString(certNode);

            try {
                CertificateFactory cf = CertificateFactory.getInstance("X509");
                X509Certificate x509Cert = (X509Certificate) cf.generateCertificate(
                        new ByteArrayInputStream(cert.getBytes(StandardCharsets.UTF_8))
                );
                x509Cert.checkValidity();

                WxPlatCertDO wxPlatCertDO = new WxPlatCertDO();
                wxPlatCertDO.setCertificate(x509Cert);
                wxPlatCertDO.setCert(cert);
                wxPlatCertDO.setEffectiveTime(format.parse(itemNode.get("effective_time").asText()));
                wxPlatCertDO.setExpireTime(format.parse(itemNode.get("expire_time").asText()));
                wxPlatCertDO.setSerialNumber(itemNode.get("serial_no").asText());

                certList.add(wxPlatCertDO);
            } catch (Exception e) {
                logger.error("update wx plat cert err", e);
            }
        }
        return certList;
    }

    /**
     * 封装的http get请求,直接使用即可,证书、签名的生成和校验已经集成
     * @param url
     * @param headerMap
     * @return
     */
    public static String wxGet(String url, HashMap<String, String> headerMap) {

        boolean lazyVerify = false;
        if (url.equals(CERT_LIST)) {
            //是下载证书的请求,允许延迟验签
            lazyVerify = true;
        }

        String token = generateToken(url, "get", null);

        headerMap = intHeader(headerMap);
        headerMap.put("Authorization", token);


        Request.Builder builder = new Request.Builder().url(url);

        for (Map.Entry<String, String> entry : headerMap.entrySet()) {
            builder.addHeader(entry.getKey(), entry.getValue());
        }

        Request request = builder.build();
        try (Response response = getClient().newCall(request).execute()) {
            return checkResponseSign(response, lazyVerify);
        } catch (IOException e) {
            throw new RuntimeException("wx okHttp get err", e);
        }
    }

    /**
     * 封装的http post请求,直接使用即可,证书、签名的生成和校验已经集成
     * @param url
     * @param json
     * @param headerMap
     * @return
     */
    public static String wxPost(String url, String json, HashMap<String, String> headerMap) {
        RequestBody requestBody = RequestBody.create(json, MediaType.parse("application/json"));

        String token = generateToken(url, "post", json);

        headerMap = intHeader(headerMap);
        headerMap.put("Authorization", token);

        Request.Builder builder = new Request.Builder().url(url).post(requestBody);
        for (Map.Entry<String, String> entry : headerMap.entrySet()) {
            builder.addHeader(entry.getKey(), entry.getValue());
        }
        try (Response response = getClient().newCall(builder.build()).execute()) {
            return checkResponseSign(response, false);
        } catch (IOException e) {
            throw new RuntimeException("wx okHttp post err", e);
        }
    }

    /**
     * 从微信下载微信平台证书
     * @return
     */
    public static List<WxPlatCertDO> getCertList() {
        String s = wxGet(CERT_LIST, null);
        return decodeWxPlatCert(s);
    }

}

上面的代码中,依赖了很多我自己写的其他类库,这里逐一介绍一下。

package com.coderbbb.blogv2.utils;

import okhttp3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.NET.ssl.HostnameVerifier;
import javax.net.ssl.SSLSession;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;


public class OkhttpUtil {

    private static OkHttpClient client = null;

    private static final Logger logger = LoggerFactory.getLogger(OkhttpUtil.class);

    private synchronized static void createClient() {
        if (client == null) {
            OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder();

            okHttpBuilder.protocols(Collections.singletonList(Protocol.HTTP_1_1));
            okHttpBuilder.connectTimeout(60, TimeUnit.SECONDS);
            okHttpBuilder.readTimeout(60, TimeUnit.SECONDS);
            okHttpBuilder.writeTimeout(60, TimeUnit.SECONDS);
            okHttpBuilder.hostnameVerifier(new HostnameVerifier() {
                @Override
                public boolean verify(String s, SSLSession sslSession) {
                    //支持所有类型https请求
                    return true;
                }
            });

            ConnectionPool pool = new ConnectionPool(200, 1, TimeUnit.SECONDS);
            okHttpBuilder.connectionPool(pool);

            client = okHttpBuilder.build();
            client.dispatcher().setMaxRequests(2000);
            client.dispatcher().setMaxRequestsPerHost(1000);
        }
    }

    public static OkHttpClient getClient() {
        if (client == null) {
            createClient();
        }
        return client;
    }

    public static String get(String url, HashMap<String, String> headerMap) {
        Request.Builder builder = new Request.Builder().url(url);

        if (headerMap != null) {
            for (Map.Entry<String, String> entry : headerMap.entrySet()) {
                builder.addHeader(entry.getKey(), entry.getValue());
            }
        }

        Request request = builder.build();

        String result = null;
        try {
            result = excute(request);
        } catch (Exception e) {
            logger.warn("http get fail:" + url + "###" + e.getMessage());
        }
        return result;
    }

    public static String postRaw(String url, String contentType, String json, HashMap<String, String> headerMap) throws Exception {
        RequestBody requestBody = RequestBody.create(json,MediaType.parse(contentType));

        Request.Builder builder = new Request.Builder().url(url).post(requestBody);
        if (headerMap != null) {
            for (Map.Entry<String, String> entry : headerMap.entrySet()) {
                builder.addHeader(entry.getKey(), entry.getValue());
            }
        }
        Request request = builder.build();
        String result = null;
        try {
            result = excute(request);
        } catch (Exception e) {
            logger.error("http post raw fail:" + url + "###" + e.getMessage());
        }
        return result;
    }

    public static String post(String url, HashMap<String, String> data, HashMap<String, String> headerMap) throws Exception {

        FormBody.Builder formBodyBuilder = new FormBody.Builder();
        for (Map.Entry<String, String> entry : data.entrySet()) {
            formBodyBuilder.add(entry.getKey(), entry.getValue());
        }
        RequestBody requestBody = formBodyBuilder.build();

        Request.Builder builder = new Request.Builder().url(url).post(requestBody);
        for (Map.Entry<String, String> entry : headerMap.entrySet()) {
            builder.addHeader(entry.getKey(), entry.getValue());
        }
        Request request = builder.build();
        String result = null;
        try {
            result = excute(request);
        } catch (Exception e) {
            logger.error("http post fail:" + url + "###" + e.getMessage());
        }
        return result;
    }

    private static String excute(Request request) throws Exception {
        Response response = getClient().newCall(request).execute();
        ResponseBody body = response.body();
        if (body != null) {
            String str = body.string();
            body.close();
            response.close();
            return str;
        }
        return null;
    }
}

package com.coderbbb.blogv2.utils;

import com.fasterxml.jackson.databind.JsonNode;

import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class AesUtil {


    private static final int TAG_LENGTH_BIT = 128;

    public static String aesKey;

    public static String decryptJsonNodeToString(JsonNode jsonNode){
        return decryptToString(
                jsonNode.get("associated_data").asText().getBytes(StandardCharsets.UTF_8),
                jsonNode.get("nonce").asText().getBytes(StandardCharsets.UTF_8),
                jsonNode.get("ciphertext").asText());
    }

    public static String decryptToString(byte[] associatedData, byte[] nonce, String ciphertext) {
        if (aesKey == null) {
            throw new RuntimeException("aesKey不能为空");
        }
        try {
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");

            SecretKeySpec key = new SecretKeySpec(aesKey.getBytes(StandardCharsets.UTF_8), "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)), StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("aes解密失败", e);
        }
    }
}

package com.coderbbb.blogv2.utils;

import com.coderbbb.blogv2.database.dto.WxCertDataDTO;
import org.apache.commons.codec.binary.Base64;
import org.springframework.core.io.ClassPathResource;

import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.X509Certificate;
import java.util.Enumeration;

/**
 * 微信支付证书解析
 *
 * @author longge93
 */
public class WxCertUtil {

    private static WxCertDataDTO wxCertDataDTO = null;

    public static String keyPass = null;

    public static String sslPath = "ssl/wx2.p12";

    private static synchronized WxCertDataDTO loadCert() {
        if (wxCertDataDTO != null) {
            return wxCertDataDTO;
        }

        if (keyPass == null) {
            throw new RuntimeException("还没有设置证书密码");
        }

        ClassPathResource classPathResource = new ClassPathResource(sslPath);

        String serialNumber;
        PublicKey publicKey;
        PrivateKey privateKey;
        try {
            KeyStore keyStore = KeyStore.getInstance("PKCS12");
            keyStore.load(classPathResource.getInputStream(), keyPass.toCharArray());

            Enumeration<String> aliases = keyStore.aliases();
            X509Certificate cert = (X509Certificate) keyStore.getCertificate(aliases.nextElement());

            //证书序列号
            serialNumber = cert.getSerialNumber().toString(16).toUpperCase();
            // 证书公钥
            publicKey = cert.getPublicKey();
            // 证书私钥
            privateKey = (PrivateKey) keyStore.getKey(keyStore.getCertificateAlias(cert), keyPass.toCharArray());
        } catch (Exception e) {
            throw new RuntimeException("读取证书失败", e);
        }

        wxCertDataDTO = new WxCertDataDTO();
        wxCertDataDTO.setPublicKey(publicKey);
        wxCertDataDTO.setPrivateKey(privateKey);
        wxCertDataDTO.setSerialNumber(serialNumber);
        wxCertDataDTO.setMchId(keyPass);

        return wxCertDataDTO;
    }

    public static WxCertDataDTO getCert() {
        if (wxCertDataDTO != null) {
            return wxCertDataDTO;
        }
        return loadCert();
    }

    public static String rsaSign(String signatureStr){

        WxCertDataDTO wxCertDataDTO = getCert();

        try {
            Signature sign = Signature.getInstance("SHA256withRSA");
            sign.initSign(wxCertDataDTO.getPrivateKey());
            sign.update(signatureStr.getBytes(StandardCharsets.UTF_8));
            return Base64.encodeBase64String(sign.sign());
        } catch (Exception e) {
            throw new RuntimeException("RSA签名失败");
        }
    }
}

源代码使用方法

<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.9.1</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>

其他

到这一步,微信支付API-V3的各种安全校验问题应该是解决了,专栏下一篇就介绍怎么使用本文封装好的HTTP GET、HTTP POST来完成整个微信支付流程。

读者要求的POJO类代码

package com.coderbbb.book1.database.dto;

import lombok.Data;

import java.security.PrivateKey;
import java.security.PublicKey;

@Data
public class WxCertDataDTO {

    /**
     * 证书序列号
     */
    private String serialNumber;

    /**
     * 证书公钥
     */
    private PublicKey publicKey;

    /**
     * 证书私钥
     */
    private PrivateKey privateKey;

    /**
     * 商户号
     */
    private String mchId;
}
package com.coderbbb.blogv2.database.dos;

import lombok.Data;

import java.security.cert.X509Certificate;
import java.util.Date;

@Data
public class WxPlatCertDO extends MybatisBaseDO{

    /**
     * 证书序列号
     */
    private String serialNumber;

    /**
     * 证书过期时间
     */
    private Date expireTime;

    /**
     * 生效时间
     */
    private Date effectiveTime;

    /**
     * X509Certificate JSON序列化
     */
    private String cert;

    /**
     * 证书
     */
    private X509Certificate certificate;
}

版权声明:《Java Springboot使用OkHttp实现微信支付API-V3签名、证书的管理和使用》为CoderBBB作者「ʘᴗʘ」的原创文章,转载请附上原文出处链接及本声明。

原文链接:
https://www.coderbbb.com/articles/4

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>