|
|
@ -0,0 +1,381 @@ |
|
|
|
package org.jeecg.modules.cityMoneyLog.resp; |
|
|
|
|
|
|
|
import com.google.gson.*; |
|
|
|
import com.google.gson.annotations.Expose; |
|
|
|
import okhttp3.Headers; |
|
|
|
import okhttp3.Response; |
|
|
|
import okio.BufferedSource; |
|
|
|
|
|
|
|
import javax.crypto.BadPaddingException; |
|
|
|
import javax.crypto.Cipher; |
|
|
|
import javax.crypto.IllegalBlockSizeException; |
|
|
|
import javax.crypto.NoSuchPaddingException; |
|
|
|
import java.io.IOException; |
|
|
|
import java.io.UncheckedIOException; |
|
|
|
import java.io.UnsupportedEncodingException; |
|
|
|
import java.net.URLEncoder; |
|
|
|
import java.nio.charset.StandardCharsets; |
|
|
|
import java.nio.file.Files; |
|
|
|
import java.nio.file.Paths; |
|
|
|
import java.security.*; |
|
|
|
import java.security.spec.InvalidKeySpecException; |
|
|
|
import java.security.spec.PKCS8EncodedKeySpec; |
|
|
|
import java.security.spec.X509EncodedKeySpec; |
|
|
|
import java.time.DateTimeException; |
|
|
|
import java.time.Duration; |
|
|
|
import java.time.Instant; |
|
|
|
import java.util.Base64; |
|
|
|
import java.util.Map; |
|
|
|
import java.util.Objects; |
|
|
|
|
|
|
|
public class WXPayUtility { |
|
|
|
private static final Gson gson = new GsonBuilder() |
|
|
|
.disableHtmlEscaping() |
|
|
|
.addSerializationExclusionStrategy(new ExclusionStrategy() { |
|
|
|
@Override |
|
|
|
public boolean shouldSkipField(FieldAttributes fieldAttributes) { |
|
|
|
final Expose expose = fieldAttributes.getAnnotation(Expose.class); |
|
|
|
return expose != null && !expose.serialize(); |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public boolean shouldSkipClass(Class<?> aClass) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
}) |
|
|
|
.addDeserializationExclusionStrategy(new ExclusionStrategy() { |
|
|
|
@Override |
|
|
|
public boolean shouldSkipField(FieldAttributes fieldAttributes) { |
|
|
|
final Expose expose = fieldAttributes.getAnnotation(Expose.class); |
|
|
|
return expose != null && !expose.deserialize(); |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public boolean shouldSkipClass(Class<?> aClass) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
}) |
|
|
|
.create(); |
|
|
|
private static final char[] SYMBOLS = |
|
|
|
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); |
|
|
|
private static final SecureRandom random = new SecureRandom(); |
|
|
|
|
|
|
|
/** |
|
|
|
* 将 Object 转换为 JSON 字符串 |
|
|
|
*/ |
|
|
|
public static String toJson(Object object) { |
|
|
|
return gson.toJson(object); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 将 JSON 字符串解析为特定类型的实例 |
|
|
|
*/ |
|
|
|
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException { |
|
|
|
return gson.fromJson(json, classOfT); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从公私钥文件路径中读取文件内容 |
|
|
|
* |
|
|
|
* @param keyPath 文件路径 |
|
|
|
* @return 文件内容 |
|
|
|
*/ |
|
|
|
private static String readKeyStringFromPath(String keyPath) { |
|
|
|
try { |
|
|
|
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); |
|
|
|
} catch (IOException e) { |
|
|
|
throw new UncheckedIOException(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 读取 PKCS#8 格式的私钥字符串并加载为私钥对象 |
|
|
|
* |
|
|
|
* @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头 |
|
|
|
* @return PrivateKey 对象 |
|
|
|
*/ |
|
|
|
public static PrivateKey loadPrivateKeyFromString(String keyString) { |
|
|
|
try { |
|
|
|
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "") |
|
|
|
.replace("-----END PRIVATE KEY-----", "") |
|
|
|
.replaceAll("\\s+", ""); |
|
|
|
return KeyFactory.getInstance("RSA").generatePrivate( |
|
|
|
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString))); |
|
|
|
} catch (NoSuchAlgorithmException e) { |
|
|
|
throw new UnsupportedOperationException(e); |
|
|
|
} catch (InvalidKeySpecException e) { |
|
|
|
throw new IllegalArgumentException(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从 PKCS#8 格式的私钥文件中加载私钥 |
|
|
|
* |
|
|
|
* @param keyPath 私钥文件路径 |
|
|
|
* @return PrivateKey 对象 |
|
|
|
*/ |
|
|
|
public static PrivateKey loadPrivateKeyFromPath(String keyPath) { |
|
|
|
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath)); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 读取 PKCS#8 格式的公钥字符串并加载为公钥对象 |
|
|
|
* |
|
|
|
* @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头 |
|
|
|
* @return PublicKey 对象 |
|
|
|
*/ |
|
|
|
public static PublicKey loadPublicKeyFromString(String keyString) { |
|
|
|
try { |
|
|
|
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "") |
|
|
|
.replace("-----END PUBLIC KEY-----", "") |
|
|
|
.replaceAll("\\s+", ""); |
|
|
|
return KeyFactory.getInstance("RSA").generatePublic( |
|
|
|
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); |
|
|
|
} catch (NoSuchAlgorithmException e) { |
|
|
|
throw new UnsupportedOperationException(e); |
|
|
|
} catch (InvalidKeySpecException e) { |
|
|
|
throw new IllegalArgumentException(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从 PKCS#8 格式的公钥文件中加载公钥 |
|
|
|
* |
|
|
|
* @param keyPath 公钥文件路径 |
|
|
|
* @return PublicKey 对象 |
|
|
|
*/ |
|
|
|
public static PublicKey loadPublicKeyFromPath(String keyPath) { |
|
|
|
return loadPublicKeyFromString(readKeyStringFromPath(keyPath)); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途 |
|
|
|
*/ |
|
|
|
public static String createNonce(int length) { |
|
|
|
char[] buf = new char[length]; |
|
|
|
for (int i = 0; i < length; ++i) { |
|
|
|
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)]; |
|
|
|
} |
|
|
|
return new String(buf); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密 |
|
|
|
* |
|
|
|
* @param publicKey 加密用公钥对象 |
|
|
|
* @param plaintext 待加密明文 |
|
|
|
* @return 加密后密文 |
|
|
|
*/ |
|
|
|
public static String encrypt(PublicKey publicKey, String plaintext) { |
|
|
|
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; |
|
|
|
|
|
|
|
try { |
|
|
|
Cipher cipher = Cipher.getInstance(transformation); |
|
|
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey); |
|
|
|
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); |
|
|
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) { |
|
|
|
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); |
|
|
|
} catch (InvalidKeyException e) { |
|
|
|
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e); |
|
|
|
} catch (BadPaddingException | IllegalBlockSizeException e) { |
|
|
|
throw new IllegalArgumentException("Plaintext is too long", e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 使用私钥按照指定算法进行签名 |
|
|
|
* |
|
|
|
* @param message 待签名串 |
|
|
|
* @param algorithm 签名算法,如 SHA256withRSA |
|
|
|
* @param privateKey 签名用私钥对象 |
|
|
|
* @return 签名结果 |
|
|
|
*/ |
|
|
|
public static String sign(String message, String algorithm, PrivateKey privateKey) { |
|
|
|
byte[] sign; |
|
|
|
try { |
|
|
|
Signature signature = Signature.getInstance(algorithm); |
|
|
|
signature.initSign(privateKey); |
|
|
|
signature.update(message.getBytes(StandardCharsets.UTF_8)); |
|
|
|
sign = signature.sign(); |
|
|
|
} catch (NoSuchAlgorithmException e) { |
|
|
|
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e); |
|
|
|
} catch (InvalidKeyException e) { |
|
|
|
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e); |
|
|
|
} catch (SignatureException e) { |
|
|
|
throw new RuntimeException("An error occurred during the sign process.", e); |
|
|
|
} |
|
|
|
return Base64.getEncoder().encodeToString(sign); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 使用公钥按照特定算法验证签名 |
|
|
|
* |
|
|
|
* @param message 待签名串 |
|
|
|
* @param signature 待验证的签名内容 |
|
|
|
* @param algorithm 签名算法,如:SHA256withRSA |
|
|
|
* @param publicKey 验签用公钥对象 |
|
|
|
* @return 签名验证是否通过 |
|
|
|
*/ |
|
|
|
public static boolean verify(String message, String signature, String algorithm, |
|
|
|
PublicKey publicKey) { |
|
|
|
try { |
|
|
|
Signature sign = Signature.getInstance(algorithm); |
|
|
|
sign.initVerify(publicKey); |
|
|
|
sign.update(message.getBytes(StandardCharsets.UTF_8)); |
|
|
|
return sign.verify(Base64.getDecoder().decode(signature)); |
|
|
|
} catch (SignatureException e) { |
|
|
|
return false; |
|
|
|
} catch (InvalidKeyException e) { |
|
|
|
throw new IllegalArgumentException("verify uses an illegal publickey.", e); |
|
|
|
} catch (NoSuchAlgorithmException e) { |
|
|
|
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 根据微信支付APIv3请求签名规则构造 Authorization 签名 |
|
|
|
* |
|
|
|
* @param mchid 商户号 |
|
|
|
* @param certificateSerialNo 商户API证书序列号 |
|
|
|
* @param privateKey 商户API证书私钥 |
|
|
|
* @param method 请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE |
|
|
|
* @param uri 请求接口的URL |
|
|
|
* @param body 请求接口的Body |
|
|
|
* @return 构造好的微信支付APIv3 Authorization 头 |
|
|
|
*/ |
|
|
|
public static String buildAuthorization(String mchid, String certificateSerialNo, |
|
|
|
PrivateKey privateKey, |
|
|
|
String method, String uri, String body) { |
|
|
|
String nonce = createNonce(32); |
|
|
|
long timestamp = Instant.now().getEpochSecond(); |
|
|
|
|
|
|
|
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce, |
|
|
|
body == null ? "" : body); |
|
|
|
|
|
|
|
String signature = sign(message, "SHA256withRSA", privateKey); |
|
|
|
|
|
|
|
return String.format( |
|
|
|
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," + |
|
|
|
"timestamp=\"%d\",serial_no=\"%s\"", |
|
|
|
mchid, nonce, signature, timestamp, certificateSerialNo); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 对参数进行 URL 编码 |
|
|
|
* |
|
|
|
* @param content 参数内容 |
|
|
|
* @return 编码后的内容 |
|
|
|
*/ |
|
|
|
public static String urlEncode(String content) { |
|
|
|
try { |
|
|
|
return URLEncoder.encode(content, StandardCharsets.UTF_8.name()); |
|
|
|
} catch (UnsupportedEncodingException e) { |
|
|
|
throw new RuntimeException(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 对参数Map进行 URL 编码,生成 QueryString |
|
|
|
* |
|
|
|
* @param params Query参数Map |
|
|
|
* @return QueryString |
|
|
|
*/ |
|
|
|
public static String urlEncode(Map<String, Object> params) { |
|
|
|
if (params == null || params.isEmpty()) { |
|
|
|
return ""; |
|
|
|
} |
|
|
|
|
|
|
|
int index = 0; |
|
|
|
StringBuilder result = new StringBuilder(); |
|
|
|
for (Map.Entry<String, Object> entry : params.entrySet()) { |
|
|
|
result.append(entry.getKey()) |
|
|
|
.append("=") |
|
|
|
.append(urlEncode(entry.getValue().toString())); |
|
|
|
index++; |
|
|
|
if (index < params.size()) { |
|
|
|
result.append("&"); |
|
|
|
} |
|
|
|
} |
|
|
|
return result.toString(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 从应答中提取 Body |
|
|
|
* |
|
|
|
* @param response HTTP 请求应答对象 |
|
|
|
* @return 应答中的Body内容,Body为空时返回空字符串 |
|
|
|
*/ |
|
|
|
public static String extractBody(Response response) { |
|
|
|
if (response.body() == null) { |
|
|
|
return ""; |
|
|
|
} |
|
|
|
|
|
|
|
try { |
|
|
|
BufferedSource source = response.body().source(); |
|
|
|
return source.readUtf8(); |
|
|
|
} catch (IOException e) { |
|
|
|
throw new RuntimeException(String.format("An error occurred during reading response body. Status: %d", response.code()), e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常 |
|
|
|
* |
|
|
|
* @param wechatpayPublicKeyId 微信支付公钥ID |
|
|
|
* @param wechatpayPublicKey 微信支付公钥对象 |
|
|
|
* @param headers 微信支付应答 Header 列表 |
|
|
|
* @param body 微信支付应答 Body |
|
|
|
*/ |
|
|
|
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey, |
|
|
|
Headers headers, |
|
|
|
String body) { |
|
|
|
String timestamp = headers.get("Wechatpay-Timestamp"); |
|
|
|
try { |
|
|
|
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); |
|
|
|
// 拒绝过期请求 |
|
|
|
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { |
|
|
|
throw new IllegalArgumentException( |
|
|
|
String.format("Validate http response,timestamp[%s] of httpResponse is expires, " |
|
|
|
+ "request-id[%s]", |
|
|
|
timestamp, headers.get("Request-ID"))); |
|
|
|
} |
|
|
|
} catch (DateTimeException | NumberFormatException e) { |
|
|
|
throw new IllegalArgumentException( |
|
|
|
String.format("Validate http response,timestamp[%s] of httpResponse is invalid, " + |
|
|
|
"request-id[%s]", timestamp, |
|
|
|
headers.get("Request-ID"))); |
|
|
|
} |
|
|
|
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), |
|
|
|
body == null ? "" : body); |
|
|
|
String serialNumber = headers.get("Wechatpay-Serial"); |
|
|
|
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { |
|
|
|
throw new IllegalArgumentException( |
|
|
|
String.format("Invalid Wechatpay-Serial, Local: %s, Remote: %s", wechatpayPublicKeyId, |
|
|
|
serialNumber)); |
|
|
|
} |
|
|
|
String signature = headers.get("Wechatpay-Signature"); |
|
|
|
|
|
|
|
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); |
|
|
|
if (!success) { |
|
|
|
throw new IllegalArgumentException( |
|
|
|
String.format("Validate response failed,the WechatPay signature is incorrect.%n" |
|
|
|
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", |
|
|
|
headers.get("Request-ID"), headers, body)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常 |
|
|
|
*/ |
|
|
|
public static class ApiException extends RuntimeException { |
|
|
|
public final int statusCode; |
|
|
|
public final String body; |
|
|
|
public final Headers headers; |
|
|
|
public ApiException(int statusCode, String body, Headers headers) { |
|
|
|
super(String.format("微信支付API访问失败,StatusCode: %s, Body: %s", statusCode, body)); |
|
|
|
this.statusCode = statusCode; |
|
|
|
this.body = body; |
|
|
|
this.headers = headers; |
|
|
|
} |
|
|
|
} |
|
|
|
} |