From a71e03b131d22774780a5297fff6ab1bcbe3fe21 Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:22:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E9=99=90?= =?UTF-8?q?=E6=B5=81=E6=8C=89=E8=A7=A3=E6=9E=90=E5=90=8E=E7=9A=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=BA=AB=E4=BB=BD=E5=85=B1=E4=BA=AB=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 #680:登录限流按解析后的用户身份共享计数 --- backend/internal/app/auth_bridge.go | 4 + backend/internal/auth/auth.go | 13 ++++ backend/internal/handler/auth.go | 7 +- .../handler/login_identity_repro_test.go | 77 +++++++++++++++++++ docs/content/docs/backend/http-api.mdx | 4 + 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/login_identity_repro_test.go diff --git a/backend/internal/app/auth_bridge.go b/backend/internal/app/auth_bridge.go index 10fc5a7a6..d8c73a3db 100644 --- a/backend/internal/app/auth_bridge.go +++ b/backend/internal/app/auth_bridge.go @@ -134,6 +134,10 @@ func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { return s.authDomain().Login(req) } +func (s *Service) LoginRateLimitSubject(account string) (string, error) { + return s.authDomain().LoginRateLimitSubject(account) +} + func (s *Service) Logout(cookieValue string) error { return s.authDomain().Logout(cookieValue) } diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 3acd9a443..76a4eda21 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -252,6 +252,19 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) { return s.createAuthSession(&user) } +// LoginRateLimitSubject uses the same account lookup as password authentication. +func (s *Service) LoginRateLimitSubject(account string) (string, error) { + account = strings.TrimSpace(account) + user, err := s.repo.UserByAccount(account) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "input:" + strings.ToLower(account), nil + } + if err != nil { + return "", err + } + return "user:" + user.ID, nil +} + func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { account := strings.TrimSpace(req.Username) user, err := s.repo.UserByAccount(account) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 967ad33e1..782acc9ca 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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) diff --git a/backend/internal/handler/login_identity_repro_test.go b/backend/internal/handler/login_identity_repro_test.go new file mode 100644 index 000000000..58bd15840 --- /dev/null +++ b/backend/internal/handler/login_identity_repro_test.go @@ -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 +} diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 52bab2319..4e7b94a8e 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -74,6 +74,10 @@ CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-re - 物理空间由后台删除队列异步释放,失败保留任务重试;接口成功不代表对象存储已经清理完成。未选中素材使用的资源,以及其他 Resource 行(包括其他用户)复用的物理对象继续保留。 - 旧画布 / 任务 / 历史快照记录本身保留,但被删媒体链接将失效。此操作不可恢复;没有数据库结构迁移。 +## 密码登录限流 + +`POST /auth/login` 保留 IP 总请求限制与十分钟账号请求限制。同一 IP 下,用户名和邮箱解析为同一用户时共享账号计数;不存在的账号仍按去除首尾空白、转小写后的输入计次。错误密码与不存在的账号使用相同的 401 响应,达到限额后均返回 429,不返回限流键或用户身份。计数仍涵盖所有登录请求,成功不清零。 + ## 注册服务协议 `POST /auth/register` 的 JSON 请求必须携带布尔值 `acceptedTerms: true`,首次创建管理员也适用。缺省、`false` 或 `null` 均按未同意处理,在创建用户、消费验证码和创建会话前返回 HTTP 400,提示“请先同意<协议名称>”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。