加固认证并发与完成候选发布准备
This commit is contained in:
parent
49ede22861
commit
554a88452a
@ -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" {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
140
backend/internal/httpapi/auth_http_load_test.go
Normal file
140
backend/internal/httpapi/auth_http_load_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"}`))
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -67,10 +67,9 @@ const {
|
||||
</p>
|
||||
<div class="warning-card">
|
||||
<strong>初始化提示</strong>
|
||||
<span
|
||||
>请先完成 DNS API 在线验证。证书签发和 HTTPS
|
||||
切换将在后续任务中执行,本页不会伪报申请成功。</span
|
||||
>
|
||||
<span>
|
||||
请先完成 DNS API 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user