完善三数据库初始化验证与凭据隔离
This commit is contained in:
parent
3fdcb7efe7
commit
8cb1921d4a
395
backend/internal/bootstrap/database.go
Normal file
395
backend/internal/bootstrap/database.go
Normal file
@ -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
|
||||
}
|
||||
159
backend/internal/bootstrap/database_test.go
Normal file
159
backend/internal/bootstrap/database_test.go
Normal file
@ -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 用户自定义路径未被拒绝")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user