diff --git a/backend/internal/bootstrap/database.go b/backend/internal/bootstrap/database.go new file mode 100644 index 0000000..f118d98 --- /dev/null +++ b/backend/internal/bootstrap/database.go @@ -0,0 +1,395 @@ +package bootstrap + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode" + + "github.com/go-sql-driver/mysql" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" +) + +const ( + databaseVerificationLifetime = 15 * time.Minute + databaseVerificationCapacity = 128 + databaseVerificationInFlightCapacity = 8 + databaseVerificationCleanupTimeout = 10 * time.Second +) + +var databaseNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) + +type DatabaseSecretStore interface { + database.CredentialReader + Put(context.Context, string, map[string]string) error + Delete(context.Context, string) error +} + +type DatabaseInput struct { + Driver string `json:"driver"` + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + TLSMode string `json:"tls_mode"` + RootCertificate string `json:"root_certificate"` +} + +type DatabaseVerificationResult struct { + Config config.DatabaseConfig `json:"database"` + CredentialRef string `json:"credential_ref,omitempty"` + VerificationToken string `json:"verification_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +type databaseVerificationSession struct { + Config config.DatabaseConfig + CredentialRef string + ExpiresAt time.Time +} + +type DatabaseVerificationManager struct { + mu sync.Mutex + base config.Config + store DatabaseSecretStore + verify func(context.Context, config.DatabaseConfig) error + now func() time.Time + sessions map[string]databaseVerificationSession + inFlight int +} + +func NewDatabaseVerificationManager(base config.Config, store DatabaseSecretStore) *DatabaseVerificationManager { + return &DatabaseVerificationManager{ + base: base, store: store, verify: database.VerifyWritable, now: time.Now, + sessions: make(map[string]databaseVerificationSession), + } +} + +func (m *DatabaseVerificationManager) Verify(ctx context.Context, input DatabaseInput) (DatabaseVerificationResult, error) { + if m == nil || m.verify == nil || m.store == nil { + return DatabaseVerificationResult{}, errors.New("数据库验证服务尚未初始化") + } + if err := m.reserve(); err != nil { + return DatabaseVerificationResult{}, err + } + released := false + defer func() { + if !released { + m.release() + } + }() + + persisted, credentials, err := buildDatabaseConfig(m.base, input) + if err != nil { + return DatabaseVerificationResult{}, err + } + credentialRef := "" + if len(credentials) > 0 { + credentialRef, err = database.NewCredentialReference() + if err != nil { + return DatabaseVerificationResult{}, err + } + persisted.CredentialRef = credentialRef + if err := m.store.Put(ctx, credentialRef, credentials); err != nil { + return DatabaseVerificationResult{}, fmt.Errorf("加密保存数据库凭据失败:%w", err) + } + } + keepCredential := false + defer func() { + if !keepCredential && credentialRef != "" { + _ = m.deleteCredential(credentialRef) + } + }() + + resolved, err := database.ResolveConfig(ctx, persisted, m.store) + if err != nil { + return DatabaseVerificationResult{}, err + } + if err := m.verify(ctx, resolved); err != nil { + return DatabaseVerificationResult{}, fmt.Errorf("数据库连接或写权限验证失败:%w", err) + } + token, err := randomToken(32) + if err != nil { + return DatabaseVerificationResult{}, fmt.Errorf("生成数据库验证令牌失败:%w", err) + } + expiresAt := m.now().UTC().Add(databaseVerificationLifetime) + m.mu.Lock() + expired := m.clearExpiredLocked(m.now().UTC()) + m.sessions[token] = databaseVerificationSession{ + Config: persisted, CredentialRef: credentialRef, ExpiresAt: expiresAt, + } + m.inFlight-- + released = true + m.mu.Unlock() + m.deleteCredentials(expired) + keepCredential = true + return DatabaseVerificationResult{ + Config: persisted, CredentialRef: credentialRef, + VerificationToken: token, ExpiresAt: expiresAt, + }, nil +} + +func (m *DatabaseVerificationManager) Check(token, credentialRef string) (config.DatabaseConfig, error) { + return m.session(token, credentialRef, false) +} + +func (m *DatabaseVerificationManager) Consume(token, credentialRef string) (config.DatabaseConfig, error) { + return m.session(token, credentialRef, true) +} + +func (m *DatabaseVerificationManager) session(token, credentialRef string, consume bool) (config.DatabaseConfig, error) { + if m == nil { + return config.DatabaseConfig{}, errors.New("数据库验证服务尚未初始化") + } + token = strings.TrimSpace(token) + if token == "" { + return config.DatabaseConfig{}, errors.New("必须先完成数据库连接和写权限测试") + } + now := m.now().UTC() + m.mu.Lock() + expired := m.clearExpiredLocked(now) + session, ok := m.sessions[token] + if !ok { + m.mu.Unlock() + m.deleteCredentials(expired) + return config.DatabaseConfig{}, errors.New("数据库验证令牌无效或已经过期,请重新测试") + } + if session.CredentialRef != strings.TrimSpace(credentialRef) { + m.mu.Unlock() + m.deleteCredentials(expired) + return config.DatabaseConfig{}, errors.New("数据库验证结果与当前配置不匹配,请重新测试") + } + if consume { + delete(m.sessions, token) + } + m.mu.Unlock() + m.deleteCredentials(expired) + return session.Config, nil +} + +func (m *DatabaseVerificationManager) DiscardAll() { + if m == nil { + return + } + m.mu.Lock() + references := make([]string, 0, len(m.sessions)) + for token, session := range m.sessions { + delete(m.sessions, token) + if session.CredentialRef != "" { + references = append(references, session.CredentialRef) + } + } + m.mu.Unlock() + m.deleteCredentials(references) +} + +func (m *DatabaseVerificationManager) reserve() error { + now := m.now().UTC() + m.mu.Lock() + expired := m.clearExpiredLocked(now) + if m.inFlight >= databaseVerificationInFlightCapacity { + m.mu.Unlock() + m.deleteCredentials(expired) + return errors.New("数据库验证请求过多,请稍后重试") + } + if len(m.sessions)+m.inFlight >= databaseVerificationCapacity { + m.mu.Unlock() + m.deleteCredentials(expired) + return errors.New("数据库验证令牌数量已达上限,请等待现有验证完成或过期后重试") + } + m.inFlight++ + m.mu.Unlock() + m.deleteCredentials(expired) + return nil +} + +func (m *DatabaseVerificationManager) release() { + m.mu.Lock() + if m.inFlight > 0 { + m.inFlight-- + } + m.mu.Unlock() +} + +func (m *DatabaseVerificationManager) clearExpiredLocked(now time.Time) []string { + references := make([]string, 0) + for token, session := range m.sessions { + if session.ExpiresAt.After(now) { + continue + } + delete(m.sessions, token) + if session.CredentialRef != "" { + references = append(references, session.CredentialRef) + } + } + return references +} + +func (m *DatabaseVerificationManager) deleteCredentials(references []string) { + for _, reference := range references { + _ = m.deleteCredential(reference) + } +} + +func (m *DatabaseVerificationManager) deleteCredential(reference string) error { + if strings.TrimSpace(reference) == "" || m == nil || m.store == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), databaseVerificationCleanupTimeout) + defer cancel() + return m.store.Delete(ctx, reference) +} + +func buildDatabaseConfig(base config.Config, input DatabaseInput) (config.DatabaseConfig, map[string]string, error) { + driver := strings.ToLower(strings.TrimSpace(input.Driver)) + result := base.Database + result.Driver = driver + result.CredentialRef = "" + if result.MaxOpenConns < 1 { + result.MaxOpenConns = 4 + } + if result.MaxIdleConns < 0 || result.MaxIdleConns > result.MaxOpenConns { + result.MaxIdleConns = 2 + } + if result.ConnMaxLifetimeMins < 0 { + result.ConnMaxLifetimeMins = 30 + } + switch driver { + case "sqlite": + if strings.TrimSpace(input.Host) != "" || input.Port != 0 || strings.TrimSpace(input.Database) != "" || + strings.TrimSpace(input.Username) != "" || input.Password != "" || strings.TrimSpace(input.RootCertificate) != "" { + return config.DatabaseConfig{}, nil, errors.New("SQLite 使用系统管理的数据文件,不接受外部连接参数") + } + result.DSN = filepath.Join(base.Paths.Data, "scert.db") + return result, nil, database.ValidateConfig(result) + case "mysql", "postgres": + // 继续进行外部数据库结构化字段校验。 + default: + return config.DatabaseConfig{}, nil, fmt.Errorf("数据库类型无效:%s", input.Driver) + } + + host, err := normalizeDatabaseHost(input.Host) + if err != nil { + return config.DatabaseConfig{}, nil, err + } + port := input.Port + if port == 0 && driver == "mysql" { + port = 3306 + } + if port == 0 && driver == "postgres" { + port = 5432 + } + if port < 1 || port > 65535 { + return config.DatabaseConfig{}, nil, fmt.Errorf("数据库端口无效:%d", port) + } + databaseName := strings.TrimSpace(input.Database) + if !databaseNamePattern.MatchString(databaseName) { + return config.DatabaseConfig{}, nil, errors.New("数据库名称格式无效") + } + username := strings.TrimSpace(input.Username) + if username == "" || len(username) > 128 || containsControl(username) { + return config.DatabaseConfig{}, nil, errors.New("数据库用户名格式无效") + } + if input.Password == "" || len(input.Password) > 4096 || containsControl(input.Password) { + return config.DatabaseConfig{}, nil, errors.New("数据库密码不能为空且不能包含控制字符") + } + + tlsMode := strings.ToLower(strings.TrimSpace(input.TLSMode)) + if driver == "mysql" { + if tlsMode == "" { + tlsMode = "true" + } + if tlsMode != "true" { + return config.DatabaseConfig{}, nil, errors.New("MySQL 初始化连接必须启用证书和主机名校验,tls_mode 只能为 true") + } + if strings.TrimSpace(input.RootCertificate) != "" { + return config.DatabaseConfig{}, nil, errors.New("当前 MySQL 初始化仅支持系统信任库,不接受自定义根证书路径") + } + mysqlConfig := mysql.NewConfig() + mysqlConfig.Net = "tcp" + mysqlConfig.Addr = net.JoinHostPort(host, strconv.Itoa(port)) + mysqlConfig.DBName = databaseName + mysqlConfig.TLSConfig = "true" + mysqlConfig.ParseTime = true + mysqlConfig.Loc = time.UTC + mysqlConfig.Params = map[string]string{"charset": "utf8mb4"} + result.DSN = mysqlConfig.FormatDSN() + } else { + if tlsMode != "" && tlsMode != "verify-full" { + return config.DatabaseConfig{}, nil, errors.New("PostgreSQL 初始化连接必须使用 sslmode=verify-full") + } + query := url.Values{"sslmode": []string{"verify-full"}} + if root := strings.TrimSpace(input.RootCertificate); root != "" { + if !filepath.IsAbs(root) { + return config.DatabaseConfig{}, nil, errors.New("PostgreSQL 根证书路径必须是绝对路径") + } + query.Set("sslrootcert", filepath.Clean(root)) + } + result.DSN = (&url.URL{ + Scheme: "postgres", Host: net.JoinHostPort(host, strconv.Itoa(port)), + Path: "/" + databaseName, RawQuery: query.Encode(), + }).String() + } + credentials := map[string]string{"username": username, "password": input.Password} + credentialRef, err := database.NewCredentialReference() + if err != nil { + return config.DatabaseConfig{}, nil, err + } + result.CredentialRef = credentialRef + if err := database.ValidateConfig(result); err != nil { + return config.DatabaseConfig{}, nil, err + } + result.CredentialRef = "" + return result, credentials, nil +} + +func normalizeDatabaseHost(value string) (string, error) { + host := strings.Trim(strings.TrimSpace(value), "[]") + if host == "" || len(host) > 253 || containsControl(host) || strings.ContainsAny(host, `/\\@?#`) { + return "", errors.New("数据库主机格式无效") + } + if net.ParseIP(host) != nil { + return host, nil + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return "", errors.New("数据库主机格式无效") + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' { + return "", errors.New("数据库主机格式无效") + } + } + } + return host, nil +} + +func containsControl(value string) bool { + for _, char := range value { + if unicode.IsControl(char) { + return true + } + } + return false +} + +func randomToken(size int) (string, error) { + buffer := make([]byte, size) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} diff --git a/backend/internal/bootstrap/database_test.go b/backend/internal/bootstrap/database_test.go new file mode 100644 index 0000000..c1a59ac --- /dev/null +++ b/backend/internal/bootstrap/database_test.go @@ -0,0 +1,159 @@ +package bootstrap + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +type memoryDatabaseStore struct { + mu sync.Mutex + values map[string]map[string]string +} + +func (s *memoryDatabaseStore) Put(_ context.Context, reference string, value map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.values == nil { + s.values = make(map[string]map[string]string) + } + s.values[reference] = map[string]string{"username": value["username"], "password": value["password"]} + return nil +} + +func (s *memoryDatabaseStore) Get(_ context.Context, reference string) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.values[reference], nil +} + +func (s *memoryDatabaseStore) Delete(_ context.Context, reference string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.values, reference) + return nil +} + +func TestBuildDatabaseConfigSupportsThreeStorageModesWithoutPersistingCredentials(t *testing.T) { + base := config.Default(t.TempDir()) + base.Paths.Data = filepath.Join(t.TempDir(), "data") + tests := []struct { + name string + input DatabaseInput + }{ + {name: "SQLite", input: DatabaseInput{Driver: "sqlite"}}, + {name: "MySQL", input: DatabaseInput{Driver: "mysql", Host: "db.example.test", Database: "scert", Username: "scert_user", Password: "mysql-secret", TLSMode: "true"}}, + {name: "PostgreSQL", input: DatabaseInput{Driver: "postgres", Host: "db.example.test", Database: "scert", Username: "scert_user", Password: "postgres-secret", TLSMode: "verify-full"}}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + persisted, credentials, err := buildDatabaseConfig(base, item.input) + if err != nil { + t.Fatalf("构造数据库配置失败:%v", err) + } + if (item.input.Username != "" && strings.Contains(persisted.DSN, item.input.Username)) || + (item.input.Password != "" && strings.Contains(persisted.DSN, item.input.Password)) { + t.Fatalf("持久化数据库配置泄露账号或密码:%s", persisted.DSN) + } + if item.input.Driver == "sqlite" { + if len(credentials) != 0 || persisted.DSN != filepath.Join(base.Paths.Data, "scert.db") { + t.Fatalf("SQLite 配置不正确:%+v credentials=%v", persisted, credentials) + } + return + } + if credentials["username"] != item.input.Username || credentials["password"] != item.input.Password { + t.Fatal("外部数据库凭据未单独返回") + } + }) + } +} + +func TestDatabaseVerificationStoresCredentialsAndConsumesTokenOnce(t *testing.T) { + base := config.Default(t.TempDir()) + store := &memoryDatabaseStore{} + manager := NewDatabaseVerificationManager(base, store) + manager.verify = func(_ context.Context, runtime config.DatabaseConfig) error { + if !strings.Contains(runtime.DSN, "database-password") { + t.Fatalf("运行时连接信息未注入内存密码:%s", runtime.DSN) + } + return nil + } + result, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "mysql", Host: "db.example.test", Database: "scert", + Username: "scert_user", Password: "database-password", TLSMode: "true", + }) + if err != nil { + t.Fatalf("数据库验证失败:%v", err) + } + if result.CredentialRef == "" || result.VerificationToken == "" { + t.Fatal("数据库验证结果缺少加密凭据引用或令牌") + } + if strings.Contains(result.Config.DSN, "scert_user") || strings.Contains(result.Config.DSN, "database-password") { + t.Fatalf("数据库验证响应泄露凭据:%+v", result.Config) + } + consumed, err := manager.Consume(result.VerificationToken, result.CredentialRef) + if err != nil || consumed.CredentialRef != result.CredentialRef { + t.Fatalf("消费数据库验证令牌失败:%v config=%+v", err, consumed) + } + if _, err := manager.Consume(result.VerificationToken, result.CredentialRef); err == nil { + t.Fatal("数据库验证令牌被重复消费") + } + store.mu.Lock() + _, retained := store.values[result.CredentialRef] + store.mu.Unlock() + if !retained { + t.Fatal("已消费的正式数据库凭据不应被验证令牌管理器删除") + } +} + +func TestDatabaseVerificationFailureAndExpiryDeleteTemporaryCredentials(t *testing.T) { + base := config.Default(t.TempDir()) + store := &memoryDatabaseStore{} + manager := NewDatabaseVerificationManager(base, store) + manager.verify = func(context.Context, config.DatabaseConfig) error { return context.DeadlineExceeded } + if _, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "postgres", Host: "db.example.test", Database: "scert", + Username: "scert_user", Password: "database-password", TLSMode: "verify-full", + }); err == nil { + t.Fatal("数据库验证失败未返回错误") + } + store.mu.Lock() + count := len(store.values) + store.mu.Unlock() + if count != 0 { + t.Fatalf("失败的数据库验证遗留临时凭据:%d", count) + } + + manager.verify = func(context.Context, config.DatabaseConfig) error { return nil } + now := time.Now().UTC() + manager.now = func() time.Time { return now } + result, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "mysql", Host: "db.example.test", Database: "scert", + Username: "scert_user", Password: "database-password", TLSMode: "true", + }) + if err != nil { + t.Fatal(err) + } + manager.now = func() time.Time { return now.Add(databaseVerificationLifetime + time.Second) } + if _, err := manager.Check(result.VerificationToken, result.CredentialRef); err == nil { + t.Fatal("过期数据库验证令牌未被拒绝") + } + store.mu.Lock() + _, exists := store.values[result.CredentialRef] + store.mu.Unlock() + if exists { + t.Fatal("过期数据库验证令牌的临时凭据未被删除") + } +} + +func TestSQLiteRejectsUserControlledPathAndExternalFields(t *testing.T) { + base := config.Default(t.TempDir()) + if _, _, err := buildDatabaseConfig(base, DatabaseInput{Driver: "sqlite", Database: "other.db"}); err == nil { + t.Fatal("SQLite 用户自定义路径未被拒绝") + } +} diff --git a/backend/internal/database/credentials.go b/backend/internal/database/credentials.go index 7876136..03d5625 100644 --- a/backend/internal/database/credentials.go +++ b/backend/internal/database/credentials.go @@ -57,17 +57,34 @@ func ValidateConfig(cfg config.DatabaseConfig) error { if err != nil { return fmt.Errorf("解析 MySQL 连接信息失败:%w", err) } - if strings.TrimSpace(parsed.User) == "" { - return fmt.Errorf("MySQL 连接信息必须包含用户名") + if strings.TrimSpace(parsed.User) != "" { + return fmt.Errorf("MySQL 用户名只能保存到加密凭据中") } return nil } - if _, err := normalizePostgresDSN(dsn); err != 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) { diff --git a/backend/internal/database/credentials_test.go b/backend/internal/database/credentials_test.go index be0a377..f05c4f4 100644 --- a/backend/internal/database/credentials_test.go +++ b/backend/internal/database/credentials_test.go @@ -46,12 +46,12 @@ func TestResolveConfigBuildsRuntimeDSNWithoutChangingPersistedConfig(t *testing. }{ { name: "MySQL", - cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert?charset=utf8mb4", CredentialRef: "secret://database/mysql"}, + cfg: config.DatabaseConfig{Driver: "mysql", DSN: "tcp(db.example.test:3306)/scert?charset=utf8mb4&tls=true", 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"}, + cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"}, wantSecret: "postgres-password", }, } @@ -74,6 +74,17 @@ func TestResolveConfigBuildsRuntimeDSNWithoutChangingPersistedConfig(t *testing. } } +func TestValidateConfigKeepsExternalDatabaseUsernameOutOfPersistedDSN(t *testing.T) { + for _, cfg := range []config.DatabaseConfig{ + {Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert?tls=true", CredentialRef: "secret://database/mysql"}, + {Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"}, + } { + if err := ValidateConfig(cfg); err == nil { + t.Fatalf("持久化配置中的数据库用户名未被拒绝:%s", cfg.Driver) + } + } +} + func TestVerifyWritableSQLiteCleansTemporaryTable(t *testing.T) { cfg := config.Default(t.TempDir()).Database if err := VerifyWritable(t.Context(), cfg); err != nil {