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": {
|
"wechat.generateQRCodeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -2426,15 +2469,39 @@ const docTemplate = `{
|
|||||||
"description": "小程序AppID",
|
"description": "小程序AppID",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"encrypted_data": {
|
||||||
|
"description": "加密数据(可选)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"iv": {
|
||||||
|
"description": "初始向量(可选)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"js_code": {
|
"js_code": {
|
||||||
"description": "登录时获取的code",
|
"description": "登录时获取的code",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_data": {
|
||||||
|
"description": "原始数据(可选,用于签名验证)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"description": "签名(可选,用于验证数据完整性)",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wechat.miniprogramLoginResponse": {
|
"wechat.miniprogramLoginResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"decrypted_data": {
|
||||||
|
"description": "解密后的用户信息(可选)",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/wechat.DecryptedUserInfo"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string"
|
"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": {
|
"wechat.generateQRCodeRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -2418,15 +2461,39 @@
|
|||||||
"description": "小程序AppID",
|
"description": "小程序AppID",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"encrypted_data": {
|
||||||
|
"description": "加密数据(可选)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"iv": {
|
||||||
|
"description": "初始向量(可选)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"js_code": {
|
"js_code": {
|
||||||
"description": "登录时获取的code",
|
"description": "登录时获取的code",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_data": {
|
||||||
|
"description": "原始数据(可选,用于签名验证)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"description": "签名(可选,用于验证数据完整性)",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wechat.miniprogramLoginResponse": {
|
"wechat.miniprogramLoginResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"decrypted_data": {
|
||||||
|
"description": "解密后的用户信息(可选)",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/wechat.DecryptedUserInfo"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -695,6 +695,34 @@ definitions:
|
|||||||
description: 真实图片地址
|
description: 真实图片地址
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
wechat.generateQRCodeRequest:
|
||||||
properties:
|
properties:
|
||||||
app_id:
|
app_id:
|
||||||
@ -726,15 +754,31 @@ definitions:
|
|||||||
app_id:
|
app_id:
|
||||||
description: 小程序AppID
|
description: 小程序AppID
|
||||||
type: string
|
type: string
|
||||||
|
encrypted_data:
|
||||||
|
description: 加密数据(可选)
|
||||||
|
type: string
|
||||||
|
iv:
|
||||||
|
description: 初始向量(可选)
|
||||||
|
type: string
|
||||||
js_code:
|
js_code:
|
||||||
description: 登录时获取的code
|
description: 登录时获取的code
|
||||||
type: string
|
type: string
|
||||||
|
raw_data:
|
||||||
|
description: 原始数据(可选,用于签名验证)
|
||||||
|
type: string
|
||||||
|
signature:
|
||||||
|
description: 签名(可选,用于验证数据完整性)
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- app_id
|
- app_id
|
||||||
- js_code
|
- js_code
|
||||||
type: object
|
type: object
|
||||||
wechat.miniprogramLoginResponse:
|
wechat.miniprogramLoginResponse:
|
||||||
properties:
|
properties:
|
||||||
|
decrypted_data:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/wechat.DecryptedUserInfo'
|
||||||
|
description: 解密后的用户信息(可选)
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
openid:
|
openid:
|
||||||
|
|||||||
@ -9,11 +9,16 @@ import (
|
|||||||
"mini-chat/internal/pkg/core"
|
"mini-chat/internal/pkg/core"
|
||||||
"mini-chat/internal/pkg/httpclient"
|
"mini-chat/internal/pkg/httpclient"
|
||||||
"mini-chat/internal/pkg/validation"
|
"mini-chat/internal/pkg/validation"
|
||||||
|
"mini-chat/internal/pkg/wechat"
|
||||||
)
|
)
|
||||||
|
|
||||||
type miniprogramLoginRequest struct {
|
type miniprogramLoginRequest struct {
|
||||||
AppID string `json:"app_id" binding:"required"` // 小程序AppID
|
AppID string `json:"app_id" binding:"required"` // 小程序AppID
|
||||||
JSCode string `json:"js_code" binding:"required"` // 登录时获取的code
|
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 {
|
type miniprogramLoginResponse struct {
|
||||||
@ -22,6 +27,7 @@ type miniprogramLoginResponse struct {
|
|||||||
OpenID string `json:"openid,omitempty"` // 用户唯一标识
|
OpenID string `json:"openid,omitempty"` // 用户唯一标识
|
||||||
SessionKey string `json:"session_key,omitempty"` // 会话密钥
|
SessionKey string `json:"session_key,omitempty"` // 会话密钥
|
||||||
UnionID string `json:"unionid,omitempty"` // 用户在开放平台的唯一标识符
|
UnionID string `json:"unionid,omitempty"` // 用户在开放平台的唯一标识符
|
||||||
|
DecryptedData *wechat.DecryptedUserInfo `json:"decrypted_data,omitempty"` // 解密后的用户信息(可选)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code2SessionResponse 微信code2Session接口响应
|
// Code2SessionResponse 微信code2Session接口响应
|
||||||
@ -90,6 +96,36 @@ func (h *handler) MiniprogramLogin() core.HandlerFunc {
|
|||||||
res.SessionKey = sessionKey
|
res.SessionKey = sessionKey
|
||||||
res.UnionID = unionID
|
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)
|
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