From 814291773f2b7ea8e9754d730e15ea27cff2cb57 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 18:12:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E4=BB=A4=E7=89=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/database.go | 41 +++++++++++ backend/internal/bootstrap/database_test.go | 68 +++++++++++++++++++ backend/internal/setup/dns_verification.go | 43 ++++++++++++ .../internal/setup/dns_verification_test.go | 35 ++++++++++ 4 files changed, 187 insertions(+) diff --git a/backend/internal/bootstrap/database.go b/backend/internal/bootstrap/database.go index ec106d2..977c3d7 100644 --- a/backend/internal/bootstrap/database.go +++ b/backend/internal/bootstrap/database.go @@ -61,6 +61,14 @@ type databaseVerificationSession struct { ExpiresAt time.Time } +// DatabaseVerificationSession 仅供服务端完整初始化事务读取验证结果。 +// 所有字段均禁止 JSON 序列化,避免凭据引用被误写入 HTTP 响应。 +type DatabaseVerificationSession struct { + Config config.DatabaseConfig `json:"-"` + CredentialRef string `json:"-"` + ExpiresAt time.Time `json:"-"` +} + type DatabaseVerificationManager struct { mu sync.Mutex base config.Config @@ -150,6 +158,19 @@ func (m *DatabaseVerificationManager) Consume(token, credentialRef string) (conf return m.session(token, credentialRef, true) } +// Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 +func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificationSession, error) { + session, err := m.resolveSession(token) + if err != nil { + return DatabaseVerificationSession{}, err + } + return DatabaseVerificationSession{ + Config: session.Config, + CredentialRef: session.CredentialRef, + ExpiresAt: session.ExpiresAt, + }, nil +} + // Discard 按验证令牌精确回收尚未消费的数据库凭据,不影响其他初始化会话。 func (m *DatabaseVerificationManager) Discard(token string) error { if m == nil { @@ -207,6 +228,26 @@ func (m *DatabaseVerificationManager) session(token, credentialRef string, consu return session.Config, nil } +func (m *DatabaseVerificationManager) resolveSession(token string) (databaseVerificationSession, error) { + if m == nil { + return databaseVerificationSession{}, errors.New("数据库验证服务尚未初始化") + } + token = strings.TrimSpace(token) + if token == "" { + return databaseVerificationSession{}, errors.New("必须先完成数据库连接和写权限测试") + } + now := m.now().UTC() + m.mu.Lock() + expired := m.clearExpiredLocked(now) + session, ok := m.sessions[token] + m.mu.Unlock() + m.deleteCredentials(expired) + if !ok { + return databaseVerificationSession{}, errors.New("数据库验证令牌无效或已经过期,请重新测试") + } + return session, nil +} + func (m *DatabaseVerificationManager) DiscardAll() { if m == nil { return diff --git a/backend/internal/bootstrap/database_test.go b/backend/internal/bootstrap/database_test.go index d3d03f2..697ca4d 100644 --- a/backend/internal/bootstrap/database_test.go +++ b/backend/internal/bootstrap/database_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "encoding/json" "path/filepath" "strings" "sync" @@ -198,3 +199,70 @@ func TestDatabaseVerificationDiscardOnlyRemovesSelectedToken(t *testing.T) { t.Fatal("数据库验证令牌被重复回收") } } + +func TestDatabaseVerificationResolveIsServerOnlyAndDoesNotConsumeToken(t *testing.T) { + base := config.Default(t.TempDir()) + store := &memoryDatabaseStore{} + manager := NewDatabaseVerificationManager(base, store) + manager.verify = func(context.Context, config.DatabaseConfig) error { 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.Fatal(err) + } + resolved, err := manager.Resolve(result.VerificationToken) + if err != nil { + t.Fatalf("按令牌解析数据库验证结果失败:%v", err) + } + if resolved.Config.CredentialRef != result.CredentialRef || resolved.CredentialRef != result.CredentialRef || resolved.ExpiresAt != result.ExpiresAt { + t.Fatalf("解析的数据库验证状态不完整:%+v", resolved) + } + encoded, err := json.Marshal(resolved) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), result.CredentialRef) || strings.Contains(string(encoded), result.Config.DSN) { + t.Fatalf("服务端数据库验证状态可被意外序列化:%s", encoded) + } + if _, err := manager.Consume(result.VerificationToken, result.CredentialRef); err != nil { + t.Fatalf("Resolve 不应消费数据库验证令牌:%v", err) + } + if _, err := manager.Resolve(result.VerificationToken); err == nil { + t.Fatal("已消费的数据库验证令牌仍可被解析") + } +} + +func TestDatabaseVerificationConsumesTokenOnlyOnceUnderConcurrency(t *testing.T) { + base := config.Default(t.TempDir()) + store := &memoryDatabaseStore{} + manager := NewDatabaseVerificationManager(base, store) + manager.verify = func(context.Context, config.DatabaseConfig) error { 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.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + _, consumeErr := manager.Consume(result.VerificationToken, result.CredentialRef) + results <- consumeErr + }() + } + close(start) + successes := 0 + for range 2 { + if <-results == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("并发消费数据库验证令牌成功次数为 %d,预期为 1", successes) + } +} diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index c4b2d5d..d6e6411 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -79,6 +79,15 @@ type dnsVerificationSession struct { ExpiresAt time.Time } +// DNSVerificationSession 仅供服务端完整初始化事务读取验证结果。 +// 所有字段均禁止 JSON 序列化,避免凭据引用被误写入 HTTP 响应。 +type DNSVerificationSession struct { + ProviderID string `json:"-"` + TestDomain string `json:"-"` + CredentialRef string `json:"-"` + ExpiresAt time.Time `json:"-"` +} + type DNSVerificationManager struct { mu sync.Mutex tester DNSCredentialTester @@ -172,6 +181,20 @@ func (m *DNSVerificationManager) Consume(token, providerID, credentialRef, testD return m.validateSession(token, providerID, credentialRef, testDomain, true) } +// Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 +func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession, error) { + session, err := m.resolveSession(token) + if err != nil { + return DNSVerificationSession{}, err + } + return DNSVerificationSession{ + ProviderID: session.ProviderID, + TestDomain: session.TestDomain, + CredentialRef: session.CredentialRef, + ExpiresAt: session.ExpiresAt, + }, nil +} + // Discard 按验证令牌精确回收尚未消费的 DNS 临时凭据,不影响其他初始化会话。 func (m *DNSVerificationManager) Discard(token string) error { if m == nil { @@ -249,6 +272,26 @@ func (m *DNSVerificationManager) validateSession(token, providerID, credentialRe return nil } +func (m *DNSVerificationManager) resolveSession(token string) (dnsVerificationSession, error) { + if m == nil { + return dnsVerificationSession{}, errors.New("DNS 在线验证服务未初始化") + } + token = strings.TrimSpace(token) + if token == "" { + return dnsVerificationSession{}, errors.New("必须先完成 DNS API 在线验证") + } + now := m.now().UTC() + m.mu.Lock() + expiredRefs := m.clearExpiredLocked(now) + session, ok := m.sessions[token] + m.mu.Unlock() + m.deleteCredentials(expiredRefs) + if !ok { + return dnsVerificationSession{}, errors.New("DNS 验证令牌无效或已经过期,请重新测试") + } + return session, nil +} + func (m *DNSVerificationManager) reserveVerificationSlot() error { if m == nil { return errors.New("DNS 在线验证服务未初始化") diff --git a/backend/internal/setup/dns_verification_test.go b/backend/internal/setup/dns_verification_test.go index 70aba2e..2a69c1a 100644 --- a/backend/internal/setup/dns_verification_test.go +++ b/backend/internal/setup/dns_verification_test.go @@ -2,7 +2,9 @@ package setup import ( "context" + "encoding/json" "errors" + "strings" "sync" "testing" "time" @@ -357,3 +359,36 @@ func TestDNSVerificationManagerNormalizesCredentialCopyBeforeTestingAndStoring(t t.Fatal("规范化过程不应修改调用方的原始凭据 Map") } } + +func TestDNSVerificationResolveIsServerOnlyAndDoesNotConsumeToken(t *testing.T) { + store := &memorySecretWriter{} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + result, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "www.example.com", + Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }) + if err != nil { + t.Fatal(err) + } + resolved, err := manager.Resolve(result.VerificationToken) + if err != nil { + t.Fatalf("按令牌解析 DNS 验证结果失败:%v", err) + } + if resolved.ProviderID != "aliyun" || resolved.TestDomain != "www.example.com" || + resolved.CredentialRef != result.CredentialRef || resolved.ExpiresAt != result.ExpiresAt { + t.Fatalf("解析的 DNS 验证状态不完整:%+v", resolved) + } + encoded, err := json.Marshal(resolved) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), result.CredentialRef) || strings.Contains(string(encoded), resolved.TestDomain) { + t.Fatalf("服务端 DNS 验证状态可被意外序列化:%s", encoded) + } + if err := manager.Consume(result.VerificationToken, resolved.ProviderID, resolved.CredentialRef, resolved.TestDomain); err != nil { + t.Fatalf("Resolve 不应消费 DNS 验证令牌:%v", err) + } + if _, err := manager.Resolve(result.VerificationToken); err == nil { + t.Fatal("已消费的 DNS 验证令牌仍可被解析") + } +}