feat(wechat): 增加微信小程序用户数据解密功能

添加对微信小程序加密用户数据的解密支持,包括签名验证和解密用户信息
更新swagger文档以反映新的API字段和数据结构
This commit is contained in:
邹方成 2025-10-18 23:08:55 +08:00
parent 4a40520a80
commit 2e86f8ae42
6 changed files with 364 additions and 7 deletions

View File

@ -2378,6 +2378,49 @@ const docTemplate = `{
}
}
},
"wechat.DecryptedUserInfo": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"gender": {
"type": "integer"
},
"nickName": {
"type": "string"
},
"openId": {
"type": "string"
},
"province": {
"type": "string"
},
"unionId": {
"type": "string"
},
"watermark": {
"$ref": "#/definitions/wechat.Watermark"
}
}
},
"wechat.Watermark": {
"type": "object",
"properties": {
"appid": {
"type": "string"
},
"timestamp": {
"type": "integer"
}
}
},
"wechat.generateQRCodeRequest": {
"type": "object",
"required": [
@ -2426,15 +2469,39 @@ const docTemplate = `{
"description": "小程序AppID",
"type": "string"
},
"encrypted_data": {
"description": "加密数据(可选)",
"type": "string"
},
"iv": {
"description": "初始向量(可选)",
"type": "string"
},
"js_code": {
"description": "登录时获取的code",
"type": "string"
},
"raw_data": {
"description": "原始数据(可选,用于签名验证)",
"type": "string"
},
"signature": {
"description": "签名(可选,用于验证数据完整性)",
"type": "string"
}
}
},
"wechat.miniprogramLoginResponse": {
"type": "object",
"properties": {
"decrypted_data": {
"description": "解密后的用户信息(可选)",
"allOf": [
{
"$ref": "#/definitions/wechat.DecryptedUserInfo"
}
]
},
"message": {
"type": "string"
},

View File

@ -2370,6 +2370,49 @@
}
}
},
"wechat.DecryptedUserInfo": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"gender": {
"type": "integer"
},
"nickName": {
"type": "string"
},
"openId": {
"type": "string"
},
"province": {
"type": "string"
},
"unionId": {
"type": "string"
},
"watermark": {
"$ref": "#/definitions/wechat.Watermark"
}
}
},
"wechat.Watermark": {
"type": "object",
"properties": {
"appid": {
"type": "string"
},
"timestamp": {
"type": "integer"
}
}
},
"wechat.generateQRCodeRequest": {
"type": "object",
"required": [
@ -2418,15 +2461,39 @@
"description": "小程序AppID",
"type": "string"
},
"encrypted_data": {
"description": "加密数据(可选)",
"type": "string"
},
"iv": {
"description": "初始向量(可选)",
"type": "string"
},
"js_code": {
"description": "登录时获取的code",
"type": "string"
},
"raw_data": {
"description": "原始数据(可选,用于签名验证)",
"type": "string"
},
"signature": {
"description": "签名(可选,用于验证数据完整性)",
"type": "string"
}
}
},
"wechat.miniprogramLoginResponse": {
"type": "object",
"properties": {
"decrypted_data": {
"description": "解密后的用户信息(可选)",
"allOf": [
{
"$ref": "#/definitions/wechat.DecryptedUserInfo"
}
]
},
"message": {
"type": "string"
},

View File

@ -695,6 +695,34 @@ definitions:
description: 真实图片地址
type: string
type: object
wechat.DecryptedUserInfo:
properties:
avatarUrl:
type: string
city:
type: string
country:
type: string
gender:
type: integer
nickName:
type: string
openId:
type: string
province:
type: string
unionId:
type: string
watermark:
$ref: '#/definitions/wechat.Watermark'
type: object
wechat.Watermark:
properties:
appid:
type: string
timestamp:
type: integer
type: object
wechat.generateQRCodeRequest:
properties:
app_id:
@ -726,15 +754,31 @@ definitions:
app_id:
description: 小程序AppID
type: string
encrypted_data:
description: 加密数据(可选)
type: string
iv:
description: 初始向量(可选)
type: string
js_code:
description: 登录时获取的code
type: string
raw_data:
description: 原始数据(可选,用于签名验证)
type: string
signature:
description: 签名(可选,用于验证数据完整性)
type: string
required:
- app_id
- js_code
type: object
wechat.miniprogramLoginResponse:
properties:
decrypted_data:
allOf:
- $ref: '#/definitions/wechat.DecryptedUserInfo'
description: 解密后的用户信息(可选)
message:
type: string
openid:

View File

@ -9,19 +9,25 @@ import (
"mini-chat/internal/pkg/core"
"mini-chat/internal/pkg/httpclient"
"mini-chat/internal/pkg/validation"
"mini-chat/internal/pkg/wechat"
)
type miniprogramLoginRequest struct {
AppID string `json:"app_id" binding:"required"` // 小程序AppID
JSCode string `json:"js_code" binding:"required"` // 登录时获取的code
AppID string `json:"app_id" binding:"required"` // 小程序AppID
JSCode string `json:"js_code" binding:"required"` // 登录时获取的code
EncryptedData string `json:"encrypted_data,omitempty"` // 加密数据(可选)
IV string `json:"iv,omitempty"` // 初始向量(可选)
RawData string `json:"raw_data,omitempty"` // 原始数据(可选,用于签名验证)
Signature string `json:"signature,omitempty"` // 签名(可选,用于验证数据完整性)
}
type miniprogramLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
OpenID string `json:"openid,omitempty"` // 用户唯一标识
SessionKey string `json:"session_key,omitempty"` // 会话密钥
UnionID string `json:"unionid,omitempty"` // 用户在开放平台的唯一标识符
Success bool `json:"success"`
Message string `json:"message"`
OpenID string `json:"openid,omitempty"` // 用户唯一标识
SessionKey string `json:"session_key,omitempty"` // 会话密钥
UnionID string `json:"unionid,omitempty"` // 用户在开放平台的唯一标识符
DecryptedData *wechat.DecryptedUserInfo `json:"decrypted_data,omitempty"` // 解密后的用户信息(可选)
}
// Code2SessionResponse 微信code2Session接口响应
@ -90,6 +96,36 @@ func (h *handler) MiniprogramLogin() core.HandlerFunc {
res.SessionKey = sessionKey
res.UnionID = unionID
// 如果提供了加密数据,则进行解密
if req.EncryptedData != "" && req.IV != "" {
// 如果提供了签名验证数据,先验证签名
if req.RawData != "" && req.Signature != "" {
if !wechat.VerifySignature(req.RawData, req.Signature, sessionKey) {
h.logger.Warn("数据签名验证失败")
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
"数据签名验证失败",
))
return
}
}
// 解密用户数据
decryptedUserInfo, err := wechat.DecryptUserInfo(sessionKey, req.EncryptedData, req.IV)
if err != nil {
h.logger.Error(fmt.Sprintf("解密用户数据失败: %s", err.Error()))
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"解密用户数据失败",
))
return
}
res.DecryptedData = decryptedUserInfo
}
ctx.Payload(res)
}
}

View File

@ -0,0 +1,143 @@
package wechat
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
)
// DecryptedUserInfo 解密后的用户信息结构
type DecryptedUserInfo struct {
OpenID string `json:"openId"`
NickName string `json:"nickName"`
Gender int `json:"gender"`
City string `json:"city"`
Province string `json:"province"`
Country string `json:"country"`
AvatarURL string `json:"avatarUrl"`
UnionID string `json:"unionId,omitempty"`
Watermark Watermark `json:"watermark"`
}
// Watermark 数据水印
type Watermark struct {
AppID string `json:"appid"`
Timestamp int64 `json:"timestamp"`
}
// DecryptData 解密微信小程序加密数据
// sessionKey: 会话密钥(从 code2session 接口获取)
// encryptedData: 加密数据Base64 编码)
// iv: 初始向量Base64 编码)
// 返回解密后的 JSON 字符串
func DecryptData(sessionKey, encryptedData, iv string) (string, error) {
// 1. Base64 解码 session_key
aesKey, err := base64.StdEncoding.DecodeString(sessionKey)
if err != nil {
return "", fmt.Errorf("session_key base64 解码失败: %v", err)
}
// 2. Base64 解码加密数据
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return "", fmt.Errorf("encryptedData base64 解码失败: %v", err)
}
// 3. Base64 解码初始向量
ivBytes, err := base64.StdEncoding.DecodeString(iv)
if err != nil {
return "", fmt.Errorf("iv base64 解码失败: %v", err)
}
// 4. 验证密钥长度AES-128 需要 16 字节)
if len(aesKey) != 16 {
return "", fmt.Errorf("session_key 长度错误,期望 16 字节,实际 %d 字节", len(aesKey))
}
// 5. 验证 IV 长度AES 块大小为 16 字节)
if len(ivBytes) != 16 {
return "", fmt.Errorf("iv 长度错误,期望 16 字节,实际 %d 字节", len(ivBytes))
}
// 6. 验证密文长度(必须是 AES 块大小的倍数)
if len(cipherText)%aes.BlockSize != 0 {
return "", fmt.Errorf("密文长度错误,必须是 %d 字节的倍数,实际 %d 字节", aes.BlockSize, len(cipherText))
}
// 7. 创建 AES 解密器
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("创建 AES 解密器失败: %v", err)
}
// 8. 创建 CBC 模式解密器
mode := cipher.NewCBCDecrypter(block, ivBytes)
// 9. 解密数据
decrypted := make([]byte, len(cipherText))
mode.CryptBlocks(decrypted, cipherText)
// 10. 去除 PKCS#7 填充
decrypted, err = pkcs7Unpad(decrypted)
if err != nil {
return "", fmt.Errorf("去除填充失败: %v", err)
}
return string(decrypted), nil
}
// DecryptUserInfo 解密用户信息并返回结构化数据
func DecryptUserInfo(sessionKey, encryptedData, iv string) (*DecryptedUserInfo, error) {
decryptedJSON, err := DecryptData(sessionKey, encryptedData, iv)
if err != nil {
return nil, err
}
var userInfo DecryptedUserInfo
if err := json.Unmarshal([]byte(decryptedJSON), &userInfo); err != nil {
return nil, fmt.Errorf("解析用户信息 JSON 失败: %v", err)
}
return &userInfo, nil
}
// VerifySignature 验证数据签名
// rawData: 原始数据
// signature: 签名
// sessionKey: 会话密钥
func VerifySignature(rawData, signature, sessionKey string) bool {
// 计算签名sha1(rawData + sessionKey)
h := sha1.New()
h.Write([]byte(rawData + sessionKey))
expectedSignature := hex.EncodeToString(h.Sum(nil))
return expectedSignature == signature
}
// pkcs7Unpad 去除 PKCS#7 填充
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, fmt.Errorf("数据为空")
}
// 获取填充长度
padding := int(data[len(data)-1])
// 验证填充长度
if padding > len(data) || padding == 0 {
return nil, fmt.Errorf("无效的填充长度: %d", padding)
}
// 验证填充字节
for i := len(data) - padding; i < len(data); i++ {
if data[i] != byte(padding) {
return nil, fmt.Errorf("无效的填充字节")
}
}
return data[:len(data)-padding], nil
}

BIN
mini-chat

Binary file not shown.