194 lines
6.2 KiB
Go
194 lines
6.2 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestIncompleteSessionsFailClosed(t *testing.T) {
|
||
cfg := readyTestConfig(t.TempDir())
|
||
cfg.Admin.Username = "admin"
|
||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||
for _, field := range []string{"user_id", "username", "email", "hash", "role", "absolute", "inverted"} {
|
||
t.Run(field, func(t *testing.T) {
|
||
s := New(Dependencies{Config: cfg})
|
||
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||
session := s.authSessions[cookie.Value]
|
||
switch field {
|
||
case "user_id":
|
||
session.UserID = ""
|
||
case "username":
|
||
session.Username = ""
|
||
case "email":
|
||
session.Email = ""
|
||
case "hash":
|
||
session.PasswordHash = ""
|
||
case "role":
|
||
session.Role = "unknown"
|
||
case "absolute":
|
||
session.AbsoluteExpiresAt = time.Time{}
|
||
case "inverted":
|
||
session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second)
|
||
}
|
||
s.authSessions[cookie.Value] = session
|
||
response := authSessionRequest(s, cookie)
|
||
if response.Code != http.StatusUnauthorized {
|
||
t.Fatal("不完整会话必须拒绝")
|
||
}
|
||
if _, ok := s.authSessions[cookie.Value]; ok {
|
||
t.Fatal("失效会话必须移除")
|
||
}
|
||
cookies := response.Result().Cookies()
|
||
if len(cookies) != 1 || cookies[0].Name != authCookieName || cookies[0].MaxAge != -1 {
|
||
t.Fatalf("失效会话必须清理认证 Cookie:%+v", cookies)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLoginAdmissionConcurrentBurstAndRecovery(t *testing.T) {
|
||
s := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
||
const workers = 512
|
||
start, release := make(chan struct{}), make(chan struct{})
|
||
var attempted sync.WaitGroup
|
||
var done sync.WaitGroup
|
||
var accepted atomic.Int32
|
||
attempted.Add(workers)
|
||
done.Add(workers)
|
||
for i := 0; i < workers; i++ {
|
||
go func(i int) {
|
||
defer done.Done()
|
||
<-start
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||
r.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i%250+1)
|
||
key := loginAttemptKey(r, fmt.Sprint(i))
|
||
ok := s.reserveLogin(r, key)
|
||
if ok {
|
||
accepted.Add(1)
|
||
}
|
||
attempted.Done()
|
||
if ok {
|
||
<-release
|
||
s.releaseLogin(r, key)
|
||
}
|
||
}(i)
|
||
}
|
||
close(start)
|
||
attempted.Wait()
|
||
if accepted.Load() != loginConcurrentLimit {
|
||
t.Errorf("并发容量错误:%d", accepted.Load())
|
||
}
|
||
close(release)
|
||
done.Wait()
|
||
if s.loginInFlight != 0 || len(s.loginPending) != 0 {
|
||
t.Fatal("并发预约没有完整释放")
|
||
}
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||
r.RemoteAddr = "198.51.100.1:1234"
|
||
key := loginAttemptKey(r, "normal")
|
||
if !s.reserveLogin(r, key) {
|
||
t.Fatal("突发结束后正常用户无法恢复")
|
||
}
|
||
s.releaseLogin(r, key)
|
||
}
|
||
|
||
func TestRandomIdentifiersCannotExhaustGlobalLoginState(t *testing.T) {
|
||
cfg := readyTestConfig(t.TempDir())
|
||
cfg.Admin.Username = "admin"
|
||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||
s := New(Dependencies{Config: cfg})
|
||
now := time.Now()
|
||
s.now = func() time.Time { return now }
|
||
for i := 0; i < 5000; i++ {
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(fmt.Sprintf(`{"identifier":"unknown-%d","password":"wrong"}`, i)))
|
||
r.RemoteAddr = "192.0.2.1:1234"
|
||
w := httptest.NewRecorder()
|
||
s.Handler().ServeHTTP(w, r)
|
||
if i >= loginSourceRequestLimit && w.Code != http.StatusTooManyRequests {
|
||
t.Fatalf("随机账号绕过来源限流:%d", w.Code)
|
||
}
|
||
}
|
||
if len(s.loginAttempts) > loginSourceRequestLimit {
|
||
t.Fatal("单一来源挤占了全局失败表")
|
||
}
|
||
normalRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||
normalRequest.RemoteAddr = "198.51.100.1:1234"
|
||
response := httptest.NewRecorder()
|
||
s.Handler().ServeHTTP(response, normalRequest)
|
||
if response.Code != http.StatusOK {
|
||
t.Fatal("独立来源正常用户不能登录")
|
||
}
|
||
now = now.Add(2 * time.Minute)
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||
r.RemoteAddr = "192.0.2.1:1234"
|
||
w := httptest.NewRecorder()
|
||
s.Handler().ServeHTTP(w, r)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("来源窗口结束后无法恢复:%d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestLoginAttemptTableSaturationRecoversAfterWindow(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.recordLoginFailure(fmt.Sprint(i))
|
||
}
|
||
if s.allowLogin("normal") {
|
||
t.Fatal("满容量不能无界增加新键")
|
||
}
|
||
now = now.Add(loginWindow + time.Second)
|
||
if !s.allowLogin("normal") || len(s.loginAttempts) != 0 {
|
||
t.Fatal("窗口到期后未恢复")
|
||
}
|
||
}
|
||
|
||
func TestLoginBlockExpiresAndUserSessionQuotaRecovers(t *testing.T) {
|
||
cfg := readyTestConfig(t.TempDir())
|
||
cfg.Admin.Username = "admin"
|
||
cfg.Admin.Email = "admin@example.test"
|
||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||
s := New(Dependencies{Config: cfg})
|
||
now := time.Now()
|
||
s.now = func() time.Time { return now }
|
||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||
key := loginAttemptKey(r, "admin")
|
||
for i := 0; i < loginFailureLimit; i++ {
|
||
s.recordLoginFailure(key)
|
||
}
|
||
if s.allowLogin(key) {
|
||
t.Fatal("封禁期间不能登录")
|
||
}
|
||
now = now.Add(loginBlockDuration + time.Second)
|
||
if !s.allowLogin(key) || !s.reserveLogin(r, key) {
|
||
t.Fatal("封禁到期后必须恢复预约")
|
||
}
|
||
s.releaseLogin(r, key)
|
||
var first *http.Cookie
|
||
for i := 0; i < authUserSessionCapacity; i++ {
|
||
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||
if first == nil {
|
||
first = cookie
|
||
}
|
||
}
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||
w := httptest.NewRecorder()
|
||
s.Handler().ServeHTTP(w, request)
|
||
if w.Code != http.StatusServiceUnavailable {
|
||
t.Fatal("单账号不能占满全局会话容量")
|
||
}
|
||
logout := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||
logout.AddCookie(first)
|
||
s.Handler().ServeHTTP(httptest.NewRecorder(), logout)
|
||
if loginAuthTest(t, s, "admin", "AdminPass123", false).Code != http.StatusOK {
|
||
t.Fatal("主动退出后不能恢复登录")
|
||
}
|
||
}
|