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 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。 +