157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
|
)
|
|
|
|
// CredentialReader 读取已加密保存的数据库用户名和密码。
|
|
// 数据库密码只能在调用期间留存在内存中,不能保存到普通配置文件。
|
|
type CredentialReader interface {
|
|
Get(context.Context, string) (map[string]string, error)
|
|
}
|
|
|
|
// NewCredentialReference 为外部数据库生成不可预测的加密凭据引用。
|
|
func NewCredentialReference() (string, error) {
|
|
buffer := make([]byte, 18)
|
|
if _, err := rand.Read(buffer); err != nil {
|
|
return "", fmt.Errorf("生成数据库凭据引用失败:%w", err)
|
|
}
|
|
return "secret://database/" + hex.EncodeToString(buffer), nil
|
|
}
|
|
|
|
// ValidateConfig 校验可持久化的数据库配置。外部数据库的 DSN 不能包含密码。
|
|
func ValidateConfig(cfg config.DatabaseConfig) error {
|
|
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
|
dsn := strings.TrimSpace(cfg.DSN)
|
|
credentialRef := strings.TrimSpace(cfg.CredentialRef)
|
|
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
|
|
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
|
|
}
|
|
if dsn == "" {
|
|
return fmt.Errorf("数据库连接信息不能为空")
|
|
}
|
|
if driver == "sqlite" {
|
|
if credentialRef != "" {
|
|
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
|
|
}
|
|
return nil
|
|
}
|
|
if credentialRef == "" || !strings.HasPrefix(credentialRef, "secret://database/") {
|
|
return fmt.Errorf("外部数据库必须配置 secret://database/ 加密凭据引用")
|
|
}
|
|
if containsPassword(driver, dsn) {
|
|
return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用")
|
|
}
|
|
if driver == "mysql" {
|
|
parsed, err := mysql.ParseDSN(dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
|
|
}
|
|
if strings.TrimSpace(parsed.User) != "" {
|
|
return fmt.Errorf("MySQL 用户名只能保存到加密凭据中")
|
|
}
|
|
return nil
|
|
}
|
|
if err := validatePersistedPostgresDSN(dsn); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePersistedPostgresDSN(dsn string) error {
|
|
return config.ValidatePostgresDSN(dsn)
|
|
}
|
|
|
|
// ResolveConfig 从凭据存储中读取外部数据库账号,在内存中构造连接配置。
|
|
// 返回值绝不能保存或记录到日志。
|
|
func ResolveConfig(ctx context.Context, cfg config.DatabaseConfig, reader CredentialReader) (config.DatabaseConfig, error) {
|
|
if err := ValidateConfig(cfg); err != nil {
|
|
return config.DatabaseConfig{}, err
|
|
}
|
|
resolved := cfg
|
|
if strings.EqualFold(resolved.Driver, "sqlite") {
|
|
return resolved, nil
|
|
}
|
|
if reader == nil {
|
|
return config.DatabaseConfig{}, fmt.Errorf("数据库敏感凭据存储未初始化")
|
|
}
|
|
credentials, err := reader.Get(ctx, resolved.CredentialRef)
|
|
if err != nil {
|
|
return config.DatabaseConfig{}, fmt.Errorf("读取数据库加密凭据失败:%w", err)
|
|
}
|
|
username := strings.TrimSpace(credentials["username"])
|
|
password, exists := credentials["password"]
|
|
if username == "" || !exists || password == "" {
|
|
return config.DatabaseConfig{}, fmt.Errorf("数据库加密凭据必须包含用户名和密码")
|
|
}
|
|
if strings.EqualFold(resolved.Driver, "mysql") {
|
|
dsn, err := withMySQLCredentials(resolved.DSN, username, password)
|
|
if err != nil {
|
|
return config.DatabaseConfig{}, err
|
|
}
|
|
resolved.DSN = dsn
|
|
return resolved, nil
|
|
}
|
|
dsn, err := withPostgresCredentials(resolved.DSN, username, password)
|
|
if err != nil {
|
|
return config.DatabaseConfig{}, err
|
|
}
|
|
resolved.DSN = dsn
|
|
return resolved, nil
|
|
}
|
|
|
|
func withMySQLCredentials(dsn, username, password string) (string, error) {
|
|
parsed, err := mysql.ParseDSN(strings.TrimSpace(dsn))
|
|
if err != nil {
|
|
return "", fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
|
|
}
|
|
parsed.User = username
|
|
parsed.Passwd = password
|
|
parsed.ParseTime = true
|
|
parsed.Loc = time.UTC
|
|
return parsed.FormatDSN(), nil
|
|
}
|
|
|
|
func withPostgresCredentials(dsn, username, password string) (string, error) {
|
|
raw := strings.TrimSpace(dsn)
|
|
parsedURL, err := url.Parse(raw)
|
|
if err != nil || (parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql") {
|
|
return "", fmt.Errorf("PostgreSQL 数据库连接信息必须使用不含密码的 postgres:// URL")
|
|
}
|
|
parsedURL.User = url.UserPassword(username, password)
|
|
return parsedURL.String(), nil
|
|
}
|
|
|
|
func containsPassword(driver, dsn string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(driver)) {
|
|
case "mysql":
|
|
at := strings.Index(dsn, "@")
|
|
return at >= 0 && strings.Contains(dsn[:at], ":")
|
|
case "postgres":
|
|
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
|
|
if parsed.User == nil {
|
|
return false
|
|
}
|
|
_, exists := parsed.User.Password()
|
|
return exists
|
|
}
|
|
for _, field := range strings.Fields(dsn) {
|
|
key, _, found := strings.Cut(field, "=")
|
|
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|