From 1bb0199891a6ce06c446948157d6be76c57b09e5 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 14:42:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=A4=96=E9=83=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=87=AD=E6=8D=AE=E9=9A=94=E7=A6=BB=E4=B8=8E?= =?UTF-8?q?=E5=86=99=E5=85=A5=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 36 +++-- backend/internal/config/config.go | 47 ++++++ backend/internal/config/config_test.go | 35 ++++ backend/internal/database/credentials.go | 152 ++++++++++++++++++ backend/internal/database/credentials_test.go | 108 +++++++++++++ backend/internal/database/database.go | 19 +++ backend/internal/database/verification.go | 91 +++++++++++ 7 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 backend/internal/database/credentials.go create mode 100644 backend/internal/database/credentials_test.go create mode 100644 backend/internal/database/verification.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index f50e4be..42ea7c9 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -65,27 +65,31 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string if err != nil { return nil, fmt.Errorf("初始化日志失败:%w", err) } - db, err := database.Open(ctx, cfg.Database) - if err != nil { - _ = logger.Close() - return nil, err - } - if err := database.Migrate(ctx, db.DB, cfg.Database.Driver); err != nil { - _ = db.Close() - _ = logger.Close() - return nil, err - } secretStore, err := secretstore.Open(cfg.Paths.Config) if err != nil { - _ = db.Close() _ = logger.Close() return nil, fmt.Errorf("初始化敏感信息存储失败:%w", err) } + runtimeDatabaseConfig, err := database.ResolveConfig(ctx, cfg.Database, secretStore) + if err != nil { + _ = logger.Close() + return nil, err + } + db, err := database.Open(ctx, runtimeDatabaseConfig) + if err != nil { + _ = logger.Close() + return nil, err + } + if err := database.Migrate(ctx, db.DB, runtimeDatabaseConfig.Driver); err != nil { + _ = db.Close() + _ = logger.Close() + return nil, err + } dnsTester := dnsprovider.NewTester(dnsprovider.NewLegoFactory()) dnsVerification := setup.NewDNSVerificationManager(dnsTester, secretStore) - repo := repository.NewSQLDomainRepository(db.DB, cfg.Database.Driver) - serverRepo := repository.NewSQLServerRepository(db.DB, cfg.Database.Driver) - userRepo := repository.NewSQLUserRepository(db.DB, cfg.Database.Driver) + repo := repository.NewSQLDomainRepository(db.DB, runtimeDatabaseConfig.Driver) + serverRepo := repository.NewSQLServerRepository(db.DB, runtimeDatabaseConfig.Driver) + userRepo := repository.NewSQLUserRepository(db.DB, runtimeDatabaseConfig.Driver) if cfg.SetupReady && cfg.Admin.Username != "" && cfg.Admin.PasswordHash != "" { if err := userRepo.EnsureSuperAdmin(ctx, repository.User{ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash}); err != nil { _ = db.Close() @@ -93,7 +97,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string return nil, fmt.Errorf("初始化超级管理员用户失败:%w", err) } } - taskRepo := repository.NewSQLTaskRepository(db.DB, cfg.Database.Driver) + taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver) if recovered, err := taskRepo.RecoverInterruptedTasks(ctx, time.Now().UTC()); err != nil { _ = db.Close() _ = logger.Close() @@ -101,7 +105,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string } else if recovered > 0 { logger.Warn("\u670d\u52a1\u91cd\u542f\u524d\u672a\u5b8c\u6210\u4efb\u52a1\u5df2\u6807\u8bb0\u4e3a\u5931\u8d25", "count", recovered) } - auditRepo := repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver) + auditRepo := repository.NewSQLAuditRepository(db.DB, runtimeDatabaseConfig.Driver) if deleted, err := auditRepo.PruneExpiredAuditLogs(ctx, time.Now().UTC().AddDate(0, 0, -auditLogRetentionDays)); err != nil { logger.Warn("启动时清理过期审计日志失败", "error", err) } else if deleted > 0 { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 42790e4..a8a8baa 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -478,6 +478,21 @@ func (c Config) Validate() error { if strings.TrimSpace(c.Database.DSN) == "" { return fmt.Errorf("数据库连接信息不能为空") } + if c.Database.Driver == "sqlite" { + if strings.TrimSpace(c.Database.CredentialRef) != "" { + return fmt.Errorf("SQLite 不允许配置数据库凭据引用") + } + } else { + if strings.TrimSpace(c.Database.CredentialRef) == "" { + return fmt.Errorf("外部数据库必须配置加密凭据引用") + } + if !strings.HasPrefix(strings.TrimSpace(c.Database.CredentialRef), "secret://database/") { + return fmt.Errorf("数据库凭据引用必须使用 secret://database/ 前缀") + } + if databaseDSNContainsPassword(c.Database.Driver, c.Database.DSN) { + return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用") + } + } if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns { return fmt.Errorf("数据库连接池配置无效") } @@ -603,6 +618,38 @@ func (c Config) Validate() error { return nil } +func databaseDSNContainsPassword(driver, dsn string) bool { + driver = strings.ToLower(strings.TrimSpace(driver)) + dsn = strings.TrimSpace(dsn) + if dsn == "" { + return false + } + switch driver { + case "mysql": + at := strings.Index(dsn, "@") + if at < 0 { + return false + } + credentials := dsn[:at] + return strings.Contains(credentials, ":") + case "postgres": + if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") { + if parsed.User == nil { + return false + } + _, present := parsed.User.Password() + return present + } + for _, field := range strings.Fields(dsn) { + key, _, found := strings.Cut(field, "=") + if found && strings.EqualFold(strings.TrimSpace(key), "password") { + return true + } + } + } + return false +} + // ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。 func (c Config) ResetURL() string { rawHost := strings.TrimSpace(c.Web.Hostname) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 60dfda8..871079a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -78,6 +78,41 @@ func TestValidateRejectsInvalidPort(t *testing.T) { } } +func TestValidateProtectsExternalDatabasePasswords(t *testing.T) { + tests := []struct { + name string + edit func(*Config) + }{ + { + name: "MySQL password in DSN", + edit: func(cfg *Config) { + cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/mysql"} + }, + }, + { + name: "PostgreSQL password in DSN", + edit: func(cfg *Config) { + cfg.Database = DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"} + }, + }, + { + name: "missing external database credential reference", + edit: func(cfg *Config) { + cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"} + }, + }, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + cfg := Default(t.TempDir()) + item.edit(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatal("不安全外部数据库配置未被拒绝") + } + }) + } +} + func TestResetURLAlwaysUsesWebHostnameWithoutPort(t *testing.T) { cfg := Default(t.TempDir()) cfg.Web.Hostname = "scert.example.test" diff --git a/backend/internal/database/credentials.go b/backend/internal/database/credentials.go new file mode 100644 index 0000000..7876136 --- /dev/null +++ b/backend/internal/database/credentials.go @@ -0,0 +1,152 @@ +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 := normalizePostgresDSN(dsn); err != nil { + return err + } + 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 +} diff --git a/backend/internal/database/credentials_test.go b/backend/internal/database/credentials_test.go new file mode 100644 index 0000000..be0a377 --- /dev/null +++ b/backend/internal/database/credentials_test.go @@ -0,0 +1,108 @@ +package database + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +type memoryCredentialReader map[string]map[string]string + +func (m memoryCredentialReader) Get(_ context.Context, reference string) (map[string]string, error) { + return m[reference], nil +} + +func TestValidateConfigRequiresEncryptedExternalDatabaseCredentials(t *testing.T) { + base := config.Default(t.TempDir()).Database + for _, item := range []struct { + name string + cfg config.DatabaseConfig + }{ + {name: "SQLite cannot have credential reference", cfg: config.DatabaseConfig{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "scert.db"), CredentialRef: "secret://database/unexpected"}}, + {name: "MySQL requires reference", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"}}, + {name: "MySQL rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/test"}}, + {name: "Postgres requires secure reference", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://dns/not-database"}}, + {name: "Postgres rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/test"}}, + } { + t.Run(item.name, func(t *testing.T) { + if err := ValidateConfig(item.cfg); err == nil { + t.Fatal("不安全数据库配置未被拒绝") + } + }) + } + if err := ValidateConfig(base); err != nil { + t.Fatalf("有效 SQLite 配置被拒绝:%v", err) + } +} + +func TestResolveConfigBuildsRuntimeDSNWithoutChangingPersistedConfig(t *testing.T) { + tests := []struct { + name string + cfg config.DatabaseConfig + wantSecret string + }{ + { + name: "MySQL", + cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert?charset=utf8mb4", CredentialRef: "secret://database/mysql"}, + wantSecret: "mysql-password", + }, + { + name: "PostgreSQL", + cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"}, + wantSecret: "postgres-password", + }, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + originalDSN := item.cfg.DSN + resolved, err := ResolveConfig(t.Context(), item.cfg, memoryCredentialReader{ + item.cfg.CredentialRef: {"username": "user", "password": item.wantSecret}, + }) + if err != nil { + t.Fatalf("组装运行时连接信息失败:%v", err) + } + if !strings.Contains(resolved.DSN, item.wantSecret) { + t.Fatalf("运行时连接信息未包含内存凭据:%s", SanitizeDSN(item.cfg.Driver, resolved.DSN)) + } + if item.cfg.DSN != originalDSN || strings.Contains(item.cfg.DSN, item.wantSecret) { + t.Fatalf("持久化配置被改写或包含密码:%+v", item.cfg) + } + }) + } +} + +func TestVerifyWritableSQLiteCleansTemporaryTable(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + if err := VerifyWritable(t.Context(), cfg); err != nil { + t.Fatalf("SQLite 可写验证失败:%v", err) + } + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var count int + if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE ?`, verificationTablePrefix+"%").Scan(&count); err != nil { + t.Fatalf("检查临时表清理失败:%v", err) + } + if count != 0 { + t.Fatalf("数据库验证遗留临时表:%d", count) + } +} + +func TestNewCredentialReferenceHasDatabasePrefixAndIsUnique(t *testing.T) { + first, err := NewCredentialReference() + if err != nil { + t.Fatal(err) + } + second, err := NewCredentialReference() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(first, "secret://database/") || first == second { + t.Fatalf("数据库凭据引用不安全:first=%s second=%s", first, second) + } +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 6a04f19..c21c39c 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -22,6 +22,9 @@ type Database struct { } func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) { + if err := ValidateConfigForOpen(cfg); err != nil { + return nil, err + } driver := strings.ToLower(strings.TrimSpace(cfg.Driver)) if driver != "sqlite" && driver != "mysql" && driver != "postgres" { return nil, fmt.Errorf("数据库驱动无效:%s", cfg.Driver) @@ -74,6 +77,22 @@ func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) { return &Database{DB: db}, nil } +// ValidateConfigForOpen 接受已在内存中补全凭据的外部数据库配置。 +// 持久化配置必须使用 ValidateConfig;运行时配置由 ResolveConfig 生成。 +func ValidateConfigForOpen(cfg config.DatabaseConfig) error { + driver := strings.ToLower(strings.TrimSpace(cfg.Driver)) + if driver != "sqlite" && driver != "mysql" && driver != "postgres" { + return fmt.Errorf("数据库驱动无效:%s", cfg.Driver) + } + if strings.TrimSpace(cfg.DSN) == "" { + return fmt.Errorf("数据库连接信息不能为空") + } + if driver == "sqlite" && strings.TrimSpace(cfg.CredentialRef) != "" { + return fmt.Errorf("SQLite 不允许配置数据库凭据引用") + } + return nil +} + func configureSQLite(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil { return fmt.Errorf("配置 SQLite 忙等待失败:%w", err) diff --git a/backend/internal/database/verification.go b/backend/internal/database/verification.go new file mode 100644 index 0000000..30cb386 --- /dev/null +++ b/backend/internal/database/verification.go @@ -0,0 +1,91 @@ +package database + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +const verificationTablePrefix = "scert_bootstrap_verify_" + +// VerifyWritable 通过随机临时表执行创建、写入、读取和清理,验证数据库账号具备运行所需写权限。 +// cfg 必须是 ResolveConfig 返回的仅内存配置,因其中可能含有数据库密码。 +func VerifyWritable(ctx context.Context, cfg config.DatabaseConfig) (err error) { + db, err := Open(ctx, cfg) + if err != nil { + return err + } + defer db.Close() + + tableName, err := newVerificationTableName() + if err != nil { + return err + } + value, err := newVerificationValue() + if err != nil { + return err + } + quotedTable := quoteIdentifier(cfg.Driver, tableName) + defer func() { + cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cleanupCancel() + cleanupErr := dropVerificationTable(cleanupContext, db.DB, quotedTable) + if cleanupErr != nil && err == nil { + err = fmt.Errorf("清理数据库验证临时表失败:%w", cleanupErr) + } + }() + + if _, err := db.DB.ExecContext(ctx, "CREATE TABLE "+quotedTable+" (value TEXT NOT NULL)"); err != nil { + return fmt.Errorf("创建数据库验证临时表失败:%w", err) + } + placeholder := "?" + if strings.EqualFold(cfg.Driver, "postgres") { + placeholder = "$1" + } + if _, err := db.DB.ExecContext(ctx, "INSERT INTO "+quotedTable+" (value) VALUES ("+placeholder+")", value); err != nil { + return fmt.Errorf("写入数据库验证临时表失败:%w", err) + } + var actual string + if err := db.DB.QueryRowContext(ctx, "SELECT value FROM "+quotedTable+" LIMIT 1").Scan(&actual); err != nil { + return fmt.Errorf("读取数据库验证临时表失败:%w", err) + } + if actual != value { + return errors.New("数据库验证读取结果不一致") + } + return nil +} + +func newVerificationTableName() (string, error) { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("生成数据库验证临时表名失败:%w", err) + } + return verificationTablePrefix + hex.EncodeToString(buffer), nil +} + +func newVerificationValue() (string, error) { + buffer := make([]byte, 24) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("生成数据库验证随机值失败:%w", err) + } + return hex.EncodeToString(buffer), nil +} + +func quoteIdentifier(driver, identifier string) string { + if strings.EqualFold(driver, "mysql") { + return "`" + identifier + "`" + } + return `"` + identifier + `"` +} + +func dropVerificationTable(ctx context.Context, db *sql.DB, table string) error { + _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+table) + return err +}