From 69efc4284b96b399ff7e2555615c47120e70ec61 Mon Sep 17 00:00:00 2001 From: veypi Date: Mon, 16 Feb 2026 04:52:26 +0800 Subject: [PATCH] fix(api/oauth): encrypt ClientSecret in database Encrypt OAuth Provider ClientSecret before storing in database to prevent sensitive credential exposure in case of database breach. - Encrypt ClientSecret on create using cfg.Config.Key.Encrypt() - Encrypt ClientSecret on update when provided - Decrypt ClientSecret before use in OAuth token exchange - Add AES-GCM encryption/decryption functions to crypto package - Gracefully handle legacy plaintext secrets during transition --- api/auth/thirdparty.go | 12 +++++- api/oauth/providers/create.go | 9 +++++ api/oauth/providers/update.go | 11 +++++ libs/crypto/crypto.go | 76 +++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/api/auth/thirdparty.go b/api/auth/thirdparty.go index c66235d..ebf02f2 100644 --- a/api/auth/thirdparty.go +++ b/api/auth/thirdparty.go @@ -449,11 +449,21 @@ func exchangeGeneric(provider models.OAuthProvider, code string) (*ThirdPartyUse redirectURI = getRedirectURI(provider.Code) } + // 解密 ClientSecret + clientSecret := provider.ClientSecret + if clientSecret != "" { + decrypted, err := cfg.Config.Key.Decrypt(clientSecret) + if err == nil { + clientSecret = decrypted + } + // 如果解密失败,可能是明文存储的旧数据,继续使用原值 + } + // 构建请求参数 data := url.Values{} data.Set("code", code) data.Set("client_id", provider.ClientID) - data.Set("client_secret", provider.ClientSecret) + data.Set("client_secret", clientSecret) data.Set("redirect_uri", redirectURI) data.Set("grant_type", "authorization_code") diff --git a/api/oauth/providers/create.go b/api/oauth/providers/create.go index 84c2e21..eba7727 100644 --- a/api/oauth/providers/create.go +++ b/api/oauth/providers/create.go @@ -28,6 +28,15 @@ func create(x *vigo.X, req *models.OAuthProvider) (*models.OAuthProvider, error) // 标记为非内置 req.IsBuiltIn = false + // 加密 ClientSecret + if req.ClientSecret != "" { + encrypted, err := cfg.Config.Key.Encrypt(req.ClientSecret) + if err != nil { + return nil, vigo.ErrInternalServer.WithString("failed to encrypt client secret") + } + req.ClientSecret = encrypted + } + // 创建 if err := db.Create(req).Error; err != nil { return nil, vigo.ErrInternalServer.WithError(err) diff --git a/api/oauth/providers/update.go b/api/oauth/providers/update.go index f7552f3..1027798 100644 --- a/api/oauth/providers/update.go +++ b/api/oauth/providers/update.go @@ -37,6 +37,17 @@ func update(x *vigo.X, req *UpdateRequest) (*models.OAuthProvider, error) { req.ID = provider.ID req.IsBuiltIn = provider.IsBuiltIn + // 如果提供了新的 ClientSecret,则加密;否则保留原值 + if req.OAuthProvider.ClientSecret != "" { + encrypted, err := cfg.Config.Key.Encrypt(req.OAuthProvider.ClientSecret) + if err != nil { + return nil, vigo.ErrInternalServer.WithString("failed to encrypt client secret") + } + req.OAuthProvider.ClientSecret = encrypted + } else { + req.OAuthProvider.ClientSecret = provider.ClientSecret + } + if err := db.Save(&req.OAuthProvider).Error; err != nil { return nil, vigo.ErrInternalServer.WithError(err) } diff --git a/libs/crypto/crypto.go b/libs/crypto/crypto.go index 1f3a1f3..e3247e9 100644 --- a/libs/crypto/crypto.go +++ b/libs/crypto/crypto.go @@ -7,9 +7,13 @@ package crypto import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" "fmt" + "io" "golang.org/x/crypto/bcrypt" ) @@ -58,3 +62,75 @@ func GenerateClientID() string { func GenerateClientSecret() string { return GenerateSecret(64) } + +// deriveKey 从密钥字符串派生32字节AES密钥 +func deriveKey(key string) []byte { + hash := sha256.Sum256([]byte(key)) + return hash[:] +} + +// Encrypt 使用AES-GCM加密数据 +// key: 加密密钥(会被派生为32字节) +// plaintext: 明文数据 +// 返回: base64编码的密文(包含nonce) +func Encrypt(key, plaintext string) (string, error) { + if key == "" || plaintext == "" { + return "", fmt.Errorf("key and plaintext cannot be empty") + } + + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt 使用AES-GCM解密数据 +// key: 解密密钥 +// ciphertext: base64编码的密文(包含nonce) +// 返回: 明文数据 +func Decrypt(key, ciphertext string) (string, error) { + if key == "" || ciphertext == "" { + return "", fmt.Errorf("key and ciphertext cannot be empty") + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode ciphertext: %w", err) + } + + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, encrypted := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +}