fix(auth): 登录限流按解析后的用户身份共享计数
合并 #680:登录限流按解析后的用户身份共享计数
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,提示“请先同意<协议名称>”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。
|
||||
|
||||
Reference in New Issue
Block a user