diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 76fcc165..52a992c4 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -35,3 +35,4 @@ {"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} {"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}} {"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}} +{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}} diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go index c8ed94a0..d4251f90 100644 --- a/internal/apps/user/errs.go +++ b/internal/apps/user/errs.go @@ -27,6 +27,7 @@ const ( errUnsupportedEmailScene = "不支持的验证场景" errEmailAlreadyRegistered = "该邮箱已被注册" errEmailCodeCooldown = "验证码发送频繁,请稍后再试" + errLoginRateLimited = "登录尝试过于频繁,请稍后再试" errEmailFormatInvalid = "邮箱格式不正确" errEmailAlreadyBound = "该邮箱已被其他账号绑定" errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w" diff --git a/internal/apps/user/logics.go b/internal/apps/user/logics.go index 7454524e..5f354aaf 100644 --- a/internal/apps/user/logics.go +++ b/internal/apps/user/logics.go @@ -13,6 +13,7 @@ import ( "fmt" "math/big" "strings" + "time" "github.com/Rain-kl/Wavelet/internal/apps/oauth" db "github.com/Rain-kl/Wavelet/internal/infra/persistence" @@ -51,6 +52,47 @@ type updateProfileInput struct { Location string } +const ( + loginFailLimitKeyFormat = "login:fail:%s" + loginFailLimitMax = 20 + loginFailLimitWindow = 10 * time.Minute +) + +func loginFailLimitKey(ip string) string { + return fmt.Sprintf(loginFailLimitKeyFormat, strings.TrimSpace(ip)) +} + +func loginAttemptsBlocked(ctx context.Context, ip string) bool { + if db.Redis == nil { + return false + } + n, err := db.Redis.Get(ctx, db.PrefixedKey(loginFailLimitKey(ip))).Int() + if err != nil { + return false + } + return n >= loginFailLimitMax +} + +func recordFailedLogin(ctx context.Context, ip string) { + if db.Redis == nil { + return + } + key := db.PrefixedKey(loginFailLimitKey(ip)) + n, err := db.Redis.Incr(ctx, key).Result() + if err != nil { + return + } + if n == 1 { + _ = db.Redis.Expire(ctx, key, loginFailLimitWindow).Err() + } +} + +func clearFailedLogins(ctx context.Context, ip string) { + if db.Redis == nil { + return + } + _ = db.Redis.Del(ctx, db.PrefixedKey(loginFailLimitKey(ip))).Err() +} func isPasswordLoginEnabled(ctx context.Context) bool { enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled) if err != nil { diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 9479bf65..cdf94c98 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -68,6 +68,10 @@ func Login(c *gin.Context) { response.AbortBadRequest(c, errPasswordLoginDisabled) return } + if loginAttemptsBlocked(ctx, c.ClientIP()) { + response.AbortBadRequest(c, errLoginRateLimited) + return + } var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortBadRequest(c, err.Error()) @@ -82,11 +86,13 @@ func Login(c *gin.Context) { user, err := getUserByUsernameOrEmail(ctx, req.Username) if err != nil { pkgu.DummyCheckPassword(req.Password) + recordFailedLogin(ctx, c.ClientIP()) logger.WarnF(ctx, "[LoginAudit] failed login attempt (username not found) for input: %s, IP: %s", req.Username, c.ClientIP()) response.AbortBadRequest(c, errUsernameOrPasswordWrong) return } if !user.IsActive { + recordFailedLogin(ctx, c.ClientIP()) logger.WarnF(ctx, "[LoginAudit] banned user login attempt for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP()) response.AbortBadRequest(c, errUsernameOrPasswordWrong) return @@ -96,6 +102,7 @@ func Login(c *gin.Context) { isPlaintext := !user.IsPasswordEncrypted() if !user.CheckPassword(req.Password) { + recordFailedLogin(ctx, c.ClientIP()) logger.WarnF(ctx, "[LoginAudit] failed login attempt (incorrect password) for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP()) response.AbortBadRequest(c, errUsernameOrPasswordWrong) return @@ -124,6 +131,7 @@ func Login(c *gin.Context) { if isPlaintext { extras["need_change_password"] = true } + clearFailedLogins(ctx, c.ClientIP()) if err := oauth.SetLoginSession(ctx, c, user, extras); err != nil { response.AbortBadRequest(c, errSaveSessionFailed) return