diff --git a/docs/docs.go b/docs/docs.go index a17866f..64e74db 100644 --- a/docs/docs.go +++ b/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" }, diff --git a/docs/swagger.json b/docs/swagger.json index 44d3451..bc21469 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8b77003..1cfcb38 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/internal/api/wechat/miniprogram_login.go b/internal/api/wechat/miniprogram_login.go index 9d09f2c..0eb7a8e 100644 --- a/internal/api/wechat/miniprogram_login.go +++ b/internal/api/wechat/miniprogram_login.go @@ -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) } } diff --git a/internal/pkg/wechat/decrypt.go b/internal/pkg/wechat/decrypt.go new file mode 100644 index 0000000..192d786 --- /dev/null +++ b/internal/pkg/wechat/decrypt.go @@ -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 +} \ No newline at end of file diff --git a/mini-chat b/mini-chat deleted file mode 100755 index 79b5370..0000000 Binary files a/mini-chat and /dev/null differ