All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m41s
修改绑定邮箱接口返回登录凭证,优化用户数据迁移流程 添加用户缓存清理逻辑,确保设备绑定后数据一致性 完善邮箱验证和绑定逻辑的注释和错误处理
270 lines
6.7 KiB
Go
270 lines
6.7 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/md5"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"time"
|
||
)
|
||
|
||
func sha256Sum(b []byte) []byte {
|
||
sum := sha256.Sum256(b)
|
||
return sum[:]
|
||
}
|
||
|
||
func md5Hex(s string) string {
|
||
h := md5.Sum([]byte(s))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
func deriveKey(secret string) []byte {
|
||
return sha256Sum([]byte(secret)) // 32 bytes for AES-256
|
||
}
|
||
|
||
func deriveIV(secret, nonce string) []byte {
|
||
ivFull := sha256Sum([]byte(md5Hex(nonce) + secret)) // 32 bytes
|
||
return ivFull[:aes.BlockSize] // 16 bytes IV
|
||
}
|
||
|
||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||
pad := blockSize - len(data)%blockSize
|
||
padding := bytes.Repeat([]byte{byte(pad)}, pad)
|
||
return append(data, padding...)
|
||
}
|
||
|
||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||
if len(data) == 0 {
|
||
return nil, errors.New("invalid data length")
|
||
}
|
||
pad := int(data[len(data)-1])
|
||
if pad <= 0 || pad > aes.BlockSize || pad > len(data) {
|
||
return nil, errors.New("invalid padding")
|
||
}
|
||
for i := 0; i < pad; i++ {
|
||
if data[len(data)-1-i] != byte(pad) {
|
||
return nil, errors.New("invalid padding content")
|
||
}
|
||
}
|
||
return data[:len(data)-pad], nil
|
||
}
|
||
|
||
func genNonce() string {
|
||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||
}
|
||
|
||
func encryptPayload(plain map[string]interface{}, secret string) ([]byte, string, error) {
|
||
nonce := genNonce()
|
||
key := deriveKey(secret)
|
||
iv := deriveIV(secret, nonce)
|
||
|
||
b, err := json.Marshal(plain)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
mode := cipher.NewCBCEncrypter(block, iv)
|
||
padded := pkcs7Pad(b, aes.BlockSize)
|
||
cipherText := make([]byte, len(padded))
|
||
mode.CryptBlocks(cipherText, padded)
|
||
|
||
wrapper := map[string]string{
|
||
"data": base64.StdEncoding.EncodeToString(cipherText),
|
||
"time": nonce,
|
||
}
|
||
out, err := json.Marshal(wrapper)
|
||
return out, nonce, err
|
||
}
|
||
|
||
func decryptResponseBody(respBody []byte, secret string) (map[string]interface{}, error) {
|
||
var top map[string]interface{}
|
||
if err := json.Unmarshal(respBody, &top); err != nil {
|
||
return nil, err
|
||
}
|
||
// 响应格式可能是:
|
||
// { "code": 0, "msg": "ok", "data": { "data": "...", "time": "..." } }
|
||
// 或者直接就是 { "data": "...", "time": "..." }
|
||
var wrapper map[string]interface{}
|
||
if v, ok := top["data"].(map[string]interface{}); ok && v["data"] != nil && v["time"] != nil {
|
||
wrapper = v
|
||
} else {
|
||
wrapper = top
|
||
}
|
||
|
||
cipherB64, _ := wrapper["data"].(string)
|
||
nonce, _ := wrapper["time"].(string)
|
||
if cipherB64 == "" || nonce == "" {
|
||
return nil, errors.New("response missing data/time fields")
|
||
}
|
||
|
||
key := deriveKey(secret)
|
||
iv := deriveIV(secret, nonce)
|
||
|
||
cipherBytes, err := base64.StdEncoding.DecodeString(cipherB64)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
mode := cipher.NewCBCDecrypter(block, iv)
|
||
plainPadded := make([]byte, len(cipherBytes))
|
||
mode.CryptBlocks(plainPadded, cipherBytes)
|
||
|
||
plain, err := pkcs7Unpad(plainPadded)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var out map[string]interface{}
|
||
if err := json.Unmarshal(plain, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func bindEmailWithPassword(serverURL, secret, token, email, password, userAgent string) error {
|
||
plain := map[string]interface{}{
|
||
"email": email,
|
||
"password": password,
|
||
}
|
||
body, _, err := encryptPayload(plain, secret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", serverURL+"/v1/public/user/bind_email_with_password", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", token)
|
||
req.Header.Set("Login-Type", "device")
|
||
req.Header.Set("User-Agent", userAgent)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
respBytes, _ := io.ReadAll(resp.Body)
|
||
fmt.Println("[绑定邮箱响应]", resp.StatusCode, string(respBytes))
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("bind_email_with_password failed: %s", string(respBytes))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func main() {
|
||
secret := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||
serverURL := "http://127.0.0.1:8080"
|
||
identifier := "AP4A.241205.A17"
|
||
userAgent := "ppanel-go-test/1.0"
|
||
|
||
plain := map[string]interface{}{
|
||
"identifier": identifier,
|
||
"user_agent": userAgent,
|
||
}
|
||
body, _, err := encryptPayload(plain, secret)
|
||
if err != nil {
|
||
fmt.Println("加密失败:", err)
|
||
os.Exit(2)
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", serverURL+"/v1/auth/login/device", bytes.NewReader(body))
|
||
if err != nil {
|
||
fmt.Println("请求创建失败:", err)
|
||
os.Exit(3)
|
||
}
|
||
req.Header.Set("Login-Type", "device")
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("User-Agent", userAgent)
|
||
req.Header.Set("X-Original-Forwarded-For", "127.0.0.1")
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
fmt.Println("请求失败:", err)
|
||
os.Exit(4)
|
||
}
|
||
defer resp.Body.Close()
|
||
respBytes, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
fmt.Println("读取响应失败:", err)
|
||
os.Exit(5)
|
||
}
|
||
|
||
fmt.Println("[加密响应原文]", string(respBytes))
|
||
decrypted, err := decryptResponseBody(respBytes, secret)
|
||
if err != nil {
|
||
fmt.Println("解密失败:", err)
|
||
os.Exit(6)
|
||
}
|
||
fmt.Println("[解密响应明文]", decrypted)
|
||
|
||
var token string
|
||
if t, ok := decrypted["token"].(string); ok && t != "" {
|
||
token = t
|
||
fmt.Println("✅ 登录成功,token =", token)
|
||
} else {
|
||
fmt.Println("⚠️ 未获取到 token")
|
||
}
|
||
|
||
// 新增:根据邮箱密码绑定设备号(需提供 EMAIL 和 PASSWORD 环境变量)
|
||
email := "client@qq.com"
|
||
password := "123456"
|
||
if token != "" && email != "" && password != "" {
|
||
if err := bindEmailWithPassword(serverURL, secret, token, email, password, userAgent); err != nil {
|
||
fmt.Println("绑定邮箱失败:", err)
|
||
} else {
|
||
fmt.Println("✅ 绑定邮箱成功")
|
||
}
|
||
} else {
|
||
fmt.Println("跳过绑定:缺少 token、EMAIL 或 PASSWORD")
|
||
}
|
||
}
|
||
|
||
/*
|
||
测试 1:
|
||
设备A: AP4A.241205.017 3节点
|
||
邮箱B: client05@gmail.com 无套餐
|
||
设备A 绑定 邮箱B
|
||
结果:
|
||
设备A : 设备A 没有套餐了
|
||
邮箱B: 套餐到了 邮箱B的体系下 设备也和邮箱绑定上了
|
||
以邮箱为主;
|
||
|
||
|
||
测试 2:
|
||
设备A: AP4A.241205.018 无套餐
|
||
邮箱B: client06@gmail.com 3节点
|
||
设备A 绑定 邮箱B
|
||
结果:
|
||
设备A : 设备A 没有套餐了
|
||
邮箱B: 原有套餐还存在 设备也和邮箱绑定上了
|
||
以邮箱为主;
|
||
|
||
测试 3:
|
||
设备A: AP4A.241205.019 3节点 2025/11/2 13:12:21 2025/10/23 13:12:21
|
||
邮箱B: client07@gmail.com day套餐
|
||
设备A 绑定 邮箱B
|
||
结果:
|
||
设备A : 设备A 没有套餐了
|
||
邮箱B: 原有套餐还存在 设备也和邮箱绑定上了
|
||
以邮箱为主;
|
||
*/
|