Scert/backend/internal/database/credentials.go

170 lines
5.6 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 {
parsed, err := url.Parse(strings.TrimSpace(dsn))
if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") {
return fmt.Errorf("PostgreSQL 数据库连接信息必须使用不含账号密码的 postgres:// URL")
}
if strings.TrimSpace(parsed.Hostname()) == "" || strings.Trim(strings.TrimSpace(parsed.Path), "/") == "" {
return fmt.Errorf("PostgreSQL 连接信息必须包含主机和数据库")
}
if parsed.User != nil {
return fmt.Errorf("PostgreSQL 用户名和密码只能保存到加密凭据中")
}
if postgresSSLMode(dsn) != "verify-full" {
return fmt.Errorf("PostgreSQL 连接必须使用 sslmode=verify-full")
}
return nil
}
// 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
}