feat(wechat): 增加微信小程序用户数据解密功能
添加对微信小程序加密用户数据的解密支持,包括签名验证和解密用户信息 更新swagger文档以反映新的API字段和数据结构
This commit is contained in:
parent
4a40520a80
commit
2e86f8ae42
67
docs/docs.go
67
docs/docs.go
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
143
internal/pkg/wechat/decrypt.go
Normal file
143
internal/pkg/wechat/decrypt.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user