v2 与 v3 的区别
先看看 v2 与 v3 的区别,做到心中有数不怯场:)
V3 | 规则差异 | V2 |
---|---|---|
JSON | 参数格式 | XML |
POST、GET 或 DELETE | 提交方式 | POST |
AES-256-GCM加密 | 回调加密 | 无需加密 |
RSA 加密 | 敏感加密 | 无需加密 |
UTF-8 | 编码方式 | UTF-8 |
非对称密钥SHA256-RSA | 签名方式 | MD5 或 HMAC-SHA256 |
微信支付Api-v3 规则 官方文档 ,此规则需要你耐心细品,重复多此细品效果更佳。
以下是我细品后,总结的实现方案,在这里就分享给大家,干货多屁话少直接贴实现。
Talk is cheap. Show me the code.
获取证书序列号
通过代码获取
这里我们使用第三方的库 x509,如你知道其它获取方法欢迎留言
const cert = x509.parseCert('cert.pem 证书绝对路径')
console.log(`证书序列号:${cert.serial}`)
通过工具获取
- openssl x509 -in apiclient_cert.pem -noout -serial
- 使用证书解析工具 https://myssl.com/cert_decode.html
构建请求头
1、构建请求签名参数
/**
* 构建请求签名参数
* @param method {RequestMethod} Http 请求方式
* @param url 请求接口 /v3/certificates
* @param timestamp 获取发起请求时的系统当前时间戳
* @param nonceStr 随机字符串
* @param body 请求报文主体
*/
public static buildReqSignMessage(method: RequestMethod, url: string, timestamp: string, nonceStr: string, body: string): string {
return method
.concat('\n')
.concat(url)
.concat('\n')
.concat(timestamp)
.concat('\n')
.concat(nonceStr)
.concat('\n')
.concat(body)
.concat('\n')
}
2、使用 SHA256 with RSA 算法生成签名
/**
* SHA256withRSA
* @param data 待加密字符
* @param privatekey 私钥key key.pem fs.readFileSync(keyPath)
*/
public static sha256WithRsa(data: string, privatekey: Buffer): string {
return crypto
.createSign('RSA-SHA256')
.update(data)
.sign(privatekey, 'base64')
}
3、根据平台规则生成请求头 authorization
/**
* 获取授权认证信息
*
* @param mchId 商户号
* @param serialNo 商户API证书序列号
* @param nonceStr 请求随机串
* @param timestamp 时间戳
* @param signature 签名值
* @param authType 认证类型,目前为WECHATPAY2-SHA256-RSA2048
*/
public static getAuthorization(mchId: string, serialNo: string, nonceStr: string, timestamp: string, signature: string, authType: string): string {
let map: Map<string, string> = new Map()
map.set('mchid', mchId)
map.set('serial_no', serialNo)
map.set('nonce_str', nonceStr)
map.set('timestamp', timestamp)
map.set('signature', signature)
return authType.concat(' ').concat(this.createLinkString(map, ',', false, true))
}
4、Show Time
/**
* 构建 v3 接口所需的 Authorization
*
* @param method {RequestMethod} 请求方法
* @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接
* @param mchId 商户Id
* @param serialNo 商户 API 证书序列号
* @param key key.pem 证书
* @param body 接口请求参数
*/
public static async buildAuthorization(method: RequestMethod, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, body: string): Promise<string> {
let timestamp: string = parseInt((Date.now() / 1000).toString()).toString()
let authType: string = 'WECHATPAY2-SHA256-RSA2048'
let nonceStr: string = Kits.generateStr()
// 构建签名参数
let buildSignMessage: string = this.buildReqSignMessage(method, urlSuffix, timestamp, nonceStr, body)
// 生成签名
let signature: string = this.sha256WithRsa(key, buildSignMessage)
// 根据平台规则生成请求头 authorization
return this.getAuthorization(mchId, serialNo, nonceStr, timestamp, signature, authType)
}
封装网络请求
每个人都有个性,可使用的网络库也比较多(Axios、Fetch、Request 等),为了适配能适配这里做一代理封装。具体实现如下,网络请求库默认是使用的 Axios
1、抽离抽象接口
/**
* @author Javen
* @copyright javendev@126.com
* @description 封装网络请求工具
*/
export class HttpKit {
private static delegate: HttpDelegate = new AxiosHttpKit()
public static get getHttpDelegate(): HttpDelegate {
return this.delegate
}
public static set setHttpDelegate(delegate: HttpDelegate) {
this.delegate = delegate
}
}
export interface HttpDelegate {
httpGet(url: string, options?: any): Promise<any>
httpGetToResponse(url: string, options?: any): Promise<any>
httpPost(url: string, data: string, options?: any): Promise<any>
httpPostToResponse(url: string, data: string, options?: any): Promise<any>
httpDeleteToResponse(url: string, options?: any): Promise<any>
httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any>
upload(url: string, filePath: string, params?: string): Promise<any>
}
2、Axios 具体实现
/**
* @author Javen
* @copyright javendev@126.com
* @description 使用 Axios 实现网络请求
*/
import axios from 'axios'
import * as fs from 'fs'
import { HttpDelegate } from './HttpKit'
import * as FormData from 'form-data'
import * as https from 'https'
import concat = require('concat-stream')
export class AxiosHttpKit implements HttpDelegate {
httpGet(url: string, options?: any): Promise<any> {
return new Promise((resolve, reject) => {
axios
.get(url, options)
.then(response => {
if (response.status === 200) {
resolve(response.data)
} else {
reject(`error code ${response.status}`)
}
})
.catch(error => {
reject(error)
})
})
}
httpGetToResponse(url: string, options?: any): Promise<any> {
return new Promise(resolve => {
axios
.get(url, options)
.then(response => {
resolve(response)
})
.catch(error => {
resolve(error.response)
})
})
}
httpPost(url: string, data: string, options?: any): Promise<any> {
return new Promise((resolve, reject) => {
axios
.post(url, data, options)
.then(response => {
if (response.status === 200) {
resolve(response.data)
} else {
reject(`error code ${response.status}`)
}
})
.catch(error => {
reject(error)
})
})
}
httpPostToResponse(url: string, data: string, options?: any): Promise<any> {
return new Promise(resolve => {
axios
.post(url, data, options)
.then(response => {
resolve(response)
})
.catch(error => {
resolve(error.response)
})
})
}
httpDeleteToResponse(url: string, options?: any): Promise<any> {
return new Promise(resolve => {
axios
.delete(url, options)
.then(response => {
resolve(response)
})
.catch(error => {
resolve(error.response)
})
})
}
httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> {
return new Promise((resolve, reject) => {
let httpsAgent = new https.Agent({
pfx: certFileContent,
passphrase
})
axios
.post(url, data, { httpsAgent })
.then(response => {
if (response.status === 200) {
resolve(response.data)
} else {
reject(`error code ${response.status}`)
}
})
.catch(error => {
reject(error)
})
})
}
upload(url: string, filePath: string, params?: string): Promise<any> {
return new Promise((resolve, reject) => {
let formData = new FormData()
formData.append('media', fs.createReadStream(filePath))
if (params) {
formData.append('description', params)
}
formData.pipe(
concat({ encoding: 'buffer' }, async data => {
axios
.post(url, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
if (response.status === 200) {
resolve(response.data)
} else {
reject(`error code ${response.status}`)
}
})
.catch(error => {
reject(error)
})
})
)
})
}
}
3、使其支持 Api-v3 接口规则
/**
* 微信支付 Api-v3 get 请求
* @param urlPrefix
* @param urlSuffix
* @param mchId
* @param serialNo
* @param key
* @param params
*/
public static async exeGet(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, params?: Map<string, string>): Promise<any> {
if (params && params.size > 0) {
urlSuffix = urlSuffix.concat('?').concat(this.createLinkString(params, '&', true, false))
}
let authorization = await this.buildAuthorization(RequestMethod.GET, urlSuffix, mchId, serialNo, key, '')
return await this.get(urlPrefix.concat(urlSuffix), authorization, serialNo)
}
/**
* 微信支付 Api-v3 post 请求
* @param urlPrefix
* @param urlSuffix
* @param mchId
* @param serialNo
* @param key
* @param data
*/
public static async exePost(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, data: string): Promise<any> {
let authorization = await this.buildAuthorization(RequestMethod.POST, urlSuffix, mchId, serialNo, key, data)
return await this.post(urlPrefix.concat(urlSuffix), data, authorization, serialNo)
}
/**
* 微信支付 Api-v3 delete 请求
* @param urlPrefix
* @param urlSuffix
* @param mchId
* @param serialNo
* @param key
*/
public static async exeDelete(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer): Promise<any> {
let authorization = await this.buildAuthorization(RequestMethod.DELETE, urlSuffix, mchId, serialNo, key, '')
return await this.delete(urlPrefix.concat(urlSuffix), authorization, serialNo)
}
/**
* get 方法
* @param url 请求 url
* @param authorization 授权信息
* @param serialNumber 证书序列号
*/
public static async get(url: string, authorization: string, serialNumber?: string) {
return await HttpKit.getHttpDelegate.httpGetToResponse(url, {
headers: this.getHeaders(authorization, serialNumber)
})
}
/**
* post 方法
* @param url 请求 url
* @param authorization 授权信息
* @param serialNumber 证书序列号
*/
public static async post(url: string, data: string, authorization: string, serialNumber?: string) {
return await HttpKit.getHttpDelegate.httpPostToResponse(url, data, {
headers: this.getHeaders(authorization, serialNumber)
})
}
/**
* delete 方法
* @param url 请求 url
* @param authorization 授权信息
* @param serialNumber 证书序列号
*/
public static async delete(url: string, authorization: string, serialNumber?: string) {
return await HttpKit.getHttpDelegate.httpDeleteToResponse(url, {
headers: this.getHeaders(authorization, serialNumber)
})
}
/**
* 获取请求头
* @param authorization 授权信息
* @param serialNumber 证书序列号
*/
private static getHeaders(authorization: string, serialNumber: string): Object {
let userAgent: string = 'WeChatPay-TNWX-HttpClient/%s (%s) nodejs/%s'
userAgent = util.format(
userAgent,
'2.4.0',
os
.platform()
.concat('/')
.concat(os.release()),
process.version
)
return {
Authorization: authorization,
Accept: 'application/json',
'Content-type': 'application/json',
'Wechatpay-Serial': serialNumber,
'User-Agent': userAgent
}
}
如何使用?
这里以「获取平台证书」为例,来演示上面封装的系列方法如何使用
try {
let result = await PayKit.exeGet(
WX_DOMAIN.CHINA, //
WX_API_TYPE.GET_CERTIFICATES,
config.mchId,
x509.parseCert(config.certPath).serial,
fs.readFileSync(config.keyPath)
)
console.log(`result.data:${result.data}`)
// 应答报文主体
let data = JSON.stringify(result.data)
// 应答状态码
console.log(`status:${result.status}`)
console.log(`data:${data}`)
// http 请求头
let headers = result.headers
// 证书序列号
let serial = headers['wechatpay-serial']
// 应答时间戳
let timestamp = headers['wechatpay-timestamp']
// 应答随机串
let nonce = headers['wechatpay-nonce']
// 应答签名
let signature = headers['wechatpay-signature']
console.log(`serial:\n${serial}`)
console.log(`timestamp:\n${timestamp}`)
console.log(`nonce:\n${nonce}`)
console.log(`signature:\n${signature}`)
ctx.body = data
} catch (error) {
console.log(error)
}
至此微信支付 Api-v3 规则的接口已经测试通过。
但还有其他细节如要我们继续完善,比如 验证签名、证书和回调报文解密
证书和回调报文解密
AEAD_AES_256_GCM 解密算法实现
/**
* AEAD_AES_256_GCM 解密
* @param key apiKey3
* @param nonce 加密使用的随机串初始化向量
* @param associatedData 附加数据包
* @param ciphertext 密文
*/
public static aes256gcmDecrypt(key: string, nonce: string, associatedData: string, ciphertext: string): string {
let ciphertextBuffer = Buffer.from(ciphertext, 'base64')
let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16)
let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16)
let decipherIv = crypto.createDecipheriv('aes-256-gcm', key, nonce)
decipherIv.setAuthTag(Buffer.from(authTag))
decipherIv.setAAD(Buffer.from(associatedData))
let decryptStr = decipherIv.update(data, null, 'utf8')
decipherIv.final()
return decryptStr
}
保存微信平台证书示例
// 证书和回调报文解密
let certPath = '/Users/Javen/cert/platform_cert.pem'
try {
let decrypt = PayKit.aes256gcmDecrypt(
config.apiKey3,
ctx.app.config.AEAD_AES_256_GCM.nonce,
ctx.app.config.AEAD_AES_256_GCM.associated_data,
ctx.app.config.AEAD_AES_256_GCM.ciphertext
)
// 保存证书
fs.writeFileSync(certPath, decrypt)
ctx.body = decrypt
} catch (error) {
console.log(error)
}
验证签名
示例
// 根据序列号查证书 验证签名
let verifySignature: boolean = PayKit.verifySignature(signature, data, nonce, timestamp, fs.readFileSync(ctx.app.config.WxPayConfig.wxCertPath))
console.log(`verifySignature:${verifySignature}`)
构建应答签名参数
/**
* 构建应答签名参数
* @param timestamp 应答时间戳
* @param nonceStr 应答随机串
* @param body 应答报文主体
*/
public static buildRepSignMessage(timestamp: string, nonceStr: string, body: string): string {
return timestamp
.concat('\n')
.concat(nonceStr)
.concat('\n')
.concat(body)
.concat('\n')
}
使用平台证书验证
/**
* 验证签名
* @param signature 待验证的签名
* @param body 应答主体
* @param nonce 随机串
* @param timestamp 时间戳
* @param publicKey 平台公钥
*/
public static verifySignature(signature: string, body: string, nonce: string, timestamp: string, publicKey: Buffer): boolean {
// 构建响应体中待签名数据
let buildSignMessage: string = this.buildRepSignMessage(timestamp, nonce, body)
return Kits.sha256WithRsaVerify(publicKey, signature, buildSignMessage)
}
/**
* SHA256withRSA 验证签名
* @param publicKey 公钥key
* @param signature 待验证的签名串
* @param data 需要验证的字符串
*/
public static sha256WithRsaVerify(publicKey: Buffer, signature: string, data: string) {
return crypto
.createVerify('RSA-SHA256')
.update(data)
.verify(publicKey, signature, 'base64')
}
完整示例代码 Egg-TNWX
TNWX: TypeScript + Node.js + WeiXin 微信系开发脚手架,支持微信公众号、微信支付、微信小游戏、微信小程序、企业微信/企业号、企业微信开放平台。最最最重要的是能快速的集成至任何 Node.js 框架(Express、Nest、Egg、Koa 等)
微信支付已支持 Api-v3 以及 Api-v2 版本接口,同时支持多商户多应用,国内与境外的普通商户模式和服务商模式,v2 接口同时支持 MD5 以及 HMAC-SHA256 签名算法。
如有疑问欢迎留言或者站内私信。
问题:Error: Unsupported state or unable to authenticate data
at Decipheriv.final (node:internal/crypto/cipher:193:29)
描述:用nodejs的crypto解密支付成功消息回调报错
nodejs版本:v18.12.1
let ciphertextBuffer = Buffer.from(ciphertext, "base64"); let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16); let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16); let decipherIv = crypto.createDecipheriv("aes-256-gcm", APIv3Key, nonce); decipherIv.setAuthTag(authTag); decipherIv.setAAD(Buffer.from(associatedData, "utf-8")); let plaintextString = decipherIv.update(data, undefined, "utf-8") decipherIv.final("utf-8") return JSON.parse(plaintextString);
解密算法,
let ciphertextBuffer = Buffer.from(ciphertext, 'base64')
let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16)
let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16
这里的authTag为什么是 ciphertextBuffer的长度减16?是官方规定的吗?看了文档没有说明
请问 Note.js 版本有吗?
我这边遇到的问题是,应答没有带上签名,这种情况怎么处理呢
请问一下multipart/form-data 类型的主体该怎么写,弄那个图片上传api 总感觉奇奇怪怪的。
Node.js 版本我这边测试还有点问题暂未公开
问下 publicKey 平台公钥 是从哪获取的