加固认证并发与完成候选发布准备

This commit is contained in:
xuans 2026-09-15 22:16:25 +08:00
parent 49ede22861
commit 554a88452a
7 changed files with 181 additions and 14 deletions

View File

@ -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" {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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"}`))

View File

@ -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"

View File

@ -67,10 +67,9 @@ const {
</p>
<div class="warning-card">
<strong>初始化提示</strong>
<span
>请先完成 DNS API 在线验证。证书签发和 HTTPS
切换将在后续任务中执行,本页不会伪报申请成功。</span
>
<span>
请先完成 DNS API 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。
</span>
</div>
</template>