fix(auth): 登录限流按解析后的用户身份共享计数

合并 #680:登录限流按解析后的用户身份共享计数
This commit is contained in:
Catlaxy AI
2026-10-06 09:22:29 +08:00
committed by GitHub
parent 82b4b0c260
commit a71e03b131
5 changed files with 104 additions and 1 deletions

View File

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

View File

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

View File

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

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

View File

@@ -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,提示“请先同意<协议名称>”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。