fix(auth): 登录限流按解析后的用户身份共享计数
合并 #680:登录限流按解析后的用户身份共享计数
This commit is contained in:
@@ -124,7 +124,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
if !available || !enforceRateLimit(c, "login-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+strings.ToLower(strings.TrimSpace(req.Username)), policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
|
||||
subject, err := svc.LoginRateLimitSubject(req.Username)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+subject, policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
result, err := svc.Login(req)
|
||||
|
||||
77
backend/internal/handler/login_identity_repro_test.go
Normal file
77
backend/internal/handler/login_identity_repro_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/platform"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
)
|
||||
|
||||
func TestLoginIdentityAliasSharesThrottle(t *testing.T) {
|
||||
testLoginIdentityAttempts(t, []string{"member", "member", "member@example.com"})
|
||||
}
|
||||
|
||||
func TestLoginIdentityUnknownAndKnownResponses(t *testing.T) {
|
||||
known := testLoginIdentityAttempts(t, []string{"member@example.com", " MEMBER ", "MEMBER@EXAMPLE.COM"})
|
||||
unknown := testLoginIdentityAttempts(t, []string{"missing", " MISSING ", "missing"})
|
||||
for i := range known {
|
||||
if known[i] != unknown[i] {
|
||||
t.Fatalf("attempt %d reveals account existence: known=%s unknown=%s", i+1, known[i], unknown[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testLoginIdentityAttempts(t *testing.T, accounts []string) []string {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.Create(&model.User{ID: "member", Username: "member", Email: "member@example.com", Status: model.UserStatusActive, PasswordHash: "invalid-hash"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := platform.DefaultRuntimePolicy()
|
||||
policy.Request.LoginAccountPerTenMinutes = 2
|
||||
data, _ := json.Marshal(policy)
|
||||
if err := db.Create(&model.SystemSetting{Key: "runtime_policy", ValueJSON: string(data)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := service.New(repository.New(db), t.TempDir())
|
||||
previous := runtimeService
|
||||
ConfigureRuntime(svc)
|
||||
t.Cleanup(func() { runtimeService = previous })
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterAuthRoutes(router.Group("/api"), svc)
|
||||
var bodies []string
|
||||
for i, account := range accounts {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(fmt.Sprintf(`{"username":%q,"password":"wrong"}`, account)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
want := http.StatusUnauthorized
|
||||
if i == 2 {
|
||||
want = http.StatusTooManyRequests
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("attempt %d account=%s status=%d want=%d body=%s", i+1, account, w.Code, want, w.Body.String())
|
||||
}
|
||||
bodies = append(bodies, w.Body.String())
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
Reference in New Issue
Block a user