From 554a88452a02163ee97598a5f518f8eb0cb05f66 Mon Sep 17 00:00:00 2001
From: xuans
Date: Tue, 15 Sep 2026 22:16:25 +0800
Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E8=AE=A4=E8=AF=81=E5=B9=B6?=
=?UTF-8?q?=E5=8F=91=E4=B8=8E=E5=AE=8C=E6=88=90=E5=80=99=E9=80=89=E5=8F=91?=
=?UTF-8?q?=E5=B8=83=E5=87=86=E5=A4=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/httpapi/auth.go | 17 ++-
backend/internal/httpapi/auth_admission.go | 4 +-
.../internal/httpapi/auth_http_load_test.go | 140 ++++++++++++++++++
backend/internal/httpapi/auth_retention.go | 20 ++-
.../internal/httpapi/auth_retention_test.go | 6 +-
backend/internal/httpapi/server.go | 1 +
.../src/views/SetupWizard/SetupWizard.vue | 7 +-
7 files changed, 181 insertions(+), 14 deletions(-)
create mode 100644 backend/internal/httpapi/auth_http_load_test.go
diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go
index 9b8be16..30e06e7 100644
--- a/backend/internal/httpapi/auth.go
+++ b/backend/internal/httpapi/auth.go
@@ -192,7 +192,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
s.authMu.RLock()
session, ok := s.authSessions[token]
s.authMu.RUnlock()
- if !ok || !session.ExpiresAt.After(now) || session.AbsoluteExpiresAt.IsZero() || !session.AbsoluteExpiresAt.After(now) || session.ExpiresAt.After(session.AbsoluteExpiresAt) {
+ if !ok || !session.usableAt(now) {
if ok {
s.authMu.Lock()
delete(s.authSessions, token)
@@ -207,13 +207,21 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
s.clearAuthCookie(c)
return authSession{}, false
}
- newExpiresAt := minTime(now.Add(authSessionLifetime), session.AbsoluteExpiresAt)
s.authMu.Lock()
current, exists := s.authSessions[token]
if !exists || current.UserID != session.UserID || current.PasswordHash != session.PasswordHash || current.Role != session.Role || current.Username != session.Username || current.Email != session.Email || !current.AbsoluteExpiresAt.Equal(session.AbsoluteExpiresAt) {
s.authMu.Unlock()
return authSession{}, false
}
+ // 账号存储查询可能很慢,返回后必须按当前时间再次校验,不能复活过期会话。
+ now = s.now()
+ if !current.usableAt(now) {
+ delete(s.authSessions, token)
+ s.authMu.Unlock()
+ s.clearAuthCookie(c)
+ return authSession{}, false
+ }
+ newExpiresAt := minTime(now.Add(authSessionLifetime), current.AbsoluteExpiresAt)
refreshed := newExpiresAt.After(current.ExpiresAt)
if refreshed {
current.ExpiresAt = newExpiresAt
@@ -228,10 +236,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
}
func (s *Server) authSessionAccountIsValid(ctx context.Context, session authSession) bool {
- if strings.TrimSpace(session.UserID) == "" || strings.TrimSpace(session.PasswordHash) == "" || strings.TrimSpace(session.Username) == "" {
- return false
- }
- if session.Role != repository.UserRoleUser && session.Role != repository.UserRoleAdmin && session.Role != repository.UserRoleSuperAdmin {
+ if !session.complete() {
return false
}
if session.UserID == "primary-super-admin" {
diff --git a/backend/internal/httpapi/auth_admission.go b/backend/internal/httpapi/auth_admission.go
index 5585db6..17375bc 100644
--- a/backend/internal/httpapi/auth_admission.go
+++ b/backend/internal/httpapi/auth_admission.go
@@ -25,15 +25,17 @@ func (s *Server) reserveLogin(request *http.Request, key string) bool {
now := s.now()
s.loginMu.Lock()
defer s.loginMu.Unlock()
+ s.pruneLoginAttemptsLocked(now)
for name, source := range s.loginSources {
if source.InFlight == 0 && now.Sub(source.WindowStart) >= time.Minute {
delete(s.loginSources, name)
}
}
source, exists := s.loginSources[sourceKey]
- attempt := s.loginAttempts[key]
+ attempt, knownAttempt := s.loginAttempts[key]
if attempt.BlockedTill.After(now) || attempt.Failures+s.loginPending[key] >= loginFailureLimit ||
s.loginInFlight >= loginConcurrentLimit || source.InFlight >= loginSourceConcurrentLimit ||
+ (!knownAttempt && len(s.loginAttempts) >= authStateCapacity) ||
(!exists && len(s.loginSources) >= authStateCapacity) {
return false
}
diff --git a/backend/internal/httpapi/auth_http_load_test.go b/backend/internal/httpapi/auth_http_load_test.go
new file mode 100644
index 0000000..e58fd71
--- /dev/null
+++ b/backend/internal/httpapi/auth_http_load_test.go
@@ -0,0 +1,140 @@
+package httpapi
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/repository"
+)
+
+type blockedLoginRepository struct {
+ repository.UserRepository
+ started chan struct{}
+ release chan struct{}
+}
+
+func (r *blockedLoginRepository) FindByIdentifier(ctx context.Context, _ string) (repository.User, error) {
+ r.started <- struct{}{}
+ select {
+ case <-r.release:
+ return repository.User{}, fmt.Errorf("测试账号不存在")
+ case <-ctx.Done():
+ return repository.User{}, ctx.Err()
+ }
+}
+
+func TestHTTPLoginBurstRejectsBeforeAccountLookup(t *testing.T) {
+ users := &blockedLoginRepository{started: make(chan struct{}, 128), release: make(chan struct{})}
+ s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
+ server := httptest.NewServer(s.Handler())
+ defer server.Close()
+ client := &http.Client{Timeout: 10 * time.Second}
+ send := func(index int) int {
+ body := fmt.Sprintf(`{"identifier":"unknown-%d","password":"wrong"}`, index)
+ r, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/auth/login", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ // 不信任任意转发头;所有套接字仍来自同一个真实来源。
+ r.Header.Set("X-Forwarded-For", fmt.Sprintf("192.0.2.%d", index%250+1))
+ response, err := client.Do(r)
+ if err != nil {
+ t.Errorf("真实 HTTP 请求失败:%v", err)
+ return 0
+ }
+ defer response.Body.Close()
+ _, _ = io.Copy(io.Discard, response.Body)
+ return response.StatusCode
+ }
+ var pending sync.WaitGroup
+ for i := 0; i < loginSourceConcurrentLimit; i++ {
+ pending.Add(1)
+ go func(i int) { defer pending.Done(); send(i) }(i)
+ }
+ defer func() { close(users.release); pending.Wait() }()
+ for i := 0; i < loginSourceConcurrentLimit; i++ {
+ select {
+ case <-users.started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("请求没有到达账号查询阶段")
+ }
+ }
+ var burst sync.WaitGroup
+ for i := 0; i < 128; i++ {
+ burst.Add(1)
+ go func(i int) {
+ defer burst.Done()
+ if code := send(i + 2); code != http.StatusTooManyRequests {
+ t.Errorf("来源并发已满时必须在查询前返回 429:%d", code)
+ }
+ }(i)
+ }
+ burst.Wait()
+ if len(users.started) != 0 {
+ t.Fatal("伪造转发头绕过了并发准入")
+ }
+}
+
+func TestSessionCannotReviveAfterSlowAccountLookup(t *testing.T) {
+ users := &pausedSessionRepository{
+ started: make(chan struct{}), release: make(chan struct{}),
+ user: repository.User{ID: "operator", Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive},
+ }
+ s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
+ now := time.Now()
+ s.now = func() time.Time { return now }
+ s.authSessions["slow"] = authSession{
+ UserID: users.user.ID, Username: users.user.Username, PasswordHash: users.user.PasswordHash, Role: users.user.Role,
+ ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
+ }
+ done := make(chan *httptest.ResponseRecorder, 1)
+ go func() { done <- authSessionRequest(s, &http.Cookie{Name: authCookieName, Value: "slow"}) }()
+ select {
+ case <-users.started:
+ case <-time.After(time.Second):
+ close(users.release)
+ t.Fatal("会话没有进入账号查询阶段")
+ }
+ now = now.Add(2 * time.Minute)
+ close(users.release)
+ if response := <-done; response.Code != http.StatusUnauthorized {
+ t.Fatal("慢查询不能复活已过期会话")
+ }
+ if _, exists := s.authSessions["slow"]; exists {
+ t.Fatal("过期会话未清理")
+ }
+}
+
+func TestLoginSourceCapacityAndPendingFailureBounds(t *testing.T) {
+ s := New(Dependencies{Config: readyTestConfig(t.TempDir())})
+ now := time.Now()
+ s.now = func() time.Time { return now }
+ for i := 0; i < authStateCapacity; i++ {
+ s.loginSources[fmt.Sprint(i)] = loginSource{WindowStart: now, Requests: 1}
+ }
+ r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
+ key := loginAttemptKey(r, "admin")
+ if s.reserveLogin(r, key) || len(s.loginSources) != authStateCapacity {
+ t.Fatal("来源表饱和时必须有界拒绝")
+ }
+ now = now.Add(time.Minute)
+ if !s.reserveLogin(r, key) {
+ t.Fatal("来源表过期回收后应恢复")
+ }
+ s.releaseLogin(r, key)
+ for i := 0; i < loginFailureLimit-1; i++ {
+ s.recordLoginFailure(key)
+ }
+ if !s.reserveLogin(r, key) {
+ t.Fatal("最后一次尝试应允许进入")
+ }
+ if s.reserveLogin(r, key) {
+ t.Fatal("在途密码计算必须计入剩余失败额度")
+ }
+ s.releaseLogin(r, key)
+}
diff --git a/backend/internal/httpapi/auth_retention.go b/backend/internal/httpapi/auth_retention.go
index 06ade43..0ccd964 100644
--- a/backend/internal/httpapi/auth_retention.go
+++ b/backend/internal/httpapi/auth_retention.go
@@ -1,14 +1,30 @@
package httpapi
-import "time"
+import (
+ "strings"
+ "time"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/repository"
+)
const authStateCapacity = 4096
const authUserSessionCapacity = 16
+func (session authSession) complete() bool {
+ return strings.TrimSpace(session.UserID) != "" && strings.TrimSpace(session.Username) != "" &&
+ strings.TrimSpace(session.PasswordHash) != "" &&
+ (session.Role == repository.UserRoleUser || session.Role == repository.UserRoleAdmin || session.Role == repository.UserRoleSuperAdmin)
+}
+
+func (session authSession) usableAt(now time.Time) bool {
+ return session.complete() && session.ExpiresAt.After(now) && session.AbsoluteExpiresAt.After(now) &&
+ !session.ExpiresAt.After(session.AbsoluteExpiresAt)
+}
+
// 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。
func (s *Server) pruneAuthSessionsLocked(now time.Time) {
for token, session := range s.authSessions {
- if !session.ExpiresAt.After(now) || (!session.AbsoluteExpiresAt.IsZero() && !session.AbsoluteExpiresAt.After(now)) {
+ if !session.usableAt(now) {
delete(s.authSessions, token)
}
}
diff --git a/backend/internal/httpapi/auth_retention_test.go b/backend/internal/httpapi/auth_retention_test.go
index 1afd0f1..4cd9180 100644
--- a/backend/internal/httpapi/auth_retention_test.go
+++ b/backend/internal/httpapi/auth_retention_test.go
@@ -21,6 +21,7 @@ func TestLoginPrunesUnvisitedExpiredState(t *testing.T) {
server.now = func() time.Time { return now }
server.authSessions["expired"] = authSession{ExpiresAt: now.Add(-time.Hour)}
server.authSessions["absolute-expired"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(-time.Second)}
+ server.authSessions["incomplete"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(time.Hour)}
server.loginAttempts["old-key"] = loginAttempt{WindowStart: now.Add(-time.Hour)}
server.loginAttempts["blocked-key"] = loginAttempt{WindowStart: now.Add(-time.Hour), BlockedTill: now.Add(time.Minute)}
response := loginAuthTest(t, server, "admin", "AdminPass123", false)
@@ -46,7 +47,10 @@ func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) {
now := time.Now()
server.now = func() time.Time { return now }
for index := 0; index < authStateCapacity; index++ {
- server.authSessions[fmt.Sprint(index)] = authSession{ExpiresAt: now.Add(time.Minute)}
+ server.authSessions[fmt.Sprint(index)] = authSession{
+ UserID: fmt.Sprint(index), Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser,
+ ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
+ }
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go
index b08c3b6..a62d576 100644
--- a/backend/internal/httpapi/server.go
+++ b/backend/internal/httpapi/server.go
@@ -22,6 +22,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
+ "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue
index c942ed0..b6b97c0 100644
--- a/frontend/src/views/SetupWizard/SetupWizard.vue
+++ b/frontend/src/views/SetupWizard/SetupWizard.vue
@@ -67,10 +67,9 @@ const {
初始化提示
- 请先完成 DNS API 在线验证。证书签发和 HTTPS
- 切换将在后续任务中执行,本页不会伪报申请成功。
+
+ 请先完成 DNS API 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。
+