From 55875fb7abde981927b492688b13d8f4f024e30b Mon Sep 17 00:00:00 2001 From: koishi514-Z <1101740081@qq.com> Date: Sun, 16 Aug 2026 16:40:19 +0800 Subject: [PATCH 1/2] fix: add login brute-force protection --- "docs/RBAC\350\257\264\346\230\216.md" | 3 + internal/config/auth_legacy_env_test.go | 19 +++++++ internal/config/config.go | 19 +++++++ internal/middleware/auth_login_ratelimit.go | 56 +++++++++++++++++++ .../middleware/auth_login_ratelimit_test.go | 33 +++++++++++ internal/router/router.go | 2 +- internal/router/routes_auth_tenant.go | 16 +++++- 7 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 internal/middleware/auth_login_ratelimit.go create mode 100644 internal/middleware/auth_login_ratelimit_test.go diff --git "a/docs/RBAC\350\257\264\346\230\216.md" "b/docs/RBAC\350\257\264\346\230\216.md" index 56fc77e576..bbd305f4d7 100644 --- "a/docs/RBAC\350\257\264\346\230\216.md" +++ "b/docs/RBAC\350\257\264\346\230\216.md" @@ -119,6 +119,9 @@ auth: # self_serve(默认):任何人都可注册,自动建空间 + Owner 成员 # invite_only :禁止公开注册,新用户必须通过 /tenants/:id/members 邀请进入 registration_mode: self_serve + # 密码登录的每 IP 滑动窗口限流;默认均为 10 + login_rate_limit_max: 10 + login_rate_limit_window_minutes: 10 audit: # 审计日志保留天数;每日后台清理;默认 90;置 0 关闭清理 diff --git a/internal/config/auth_legacy_env_test.go b/internal/config/auth_legacy_env_test.go index fcf6d06623..4424d1b01a 100644 --- a/internal/config/auth_legacy_env_test.go +++ b/internal/config/auth_legacy_env_test.go @@ -103,6 +103,25 @@ func TestApplyAuthAndTenantDefaults_DefaultTenantMode(t *testing.T) { }) } +func TestApplyAuthAndTenantDefaults_LoginRateLimit(t *testing.T) { + cfg := &Config{Auth: &AuthConfig{}} + + applyAuthAndTenantDefaults(cfg) + + if cfg.Auth.LoginRateLimitMax != 10 { + t.Fatalf("login_rate_limit_max = %d, want 10", cfg.Auth.LoginRateLimitMax) + } + if cfg.Auth.LoginRateLimitWindowMinutes != 10 { + t.Fatalf("login_rate_limit_window_minutes = %d, want 10", cfg.Auth.LoginRateLimitWindowMinutes) + } + if err := ValidateConfig(&Config{Auth: &AuthConfig{LoginRateLimitMax: -1}}); err == nil { + t.Fatal("ValidateConfig unexpectedly accepted a negative login_rate_limit_max") + } + if err := ValidateConfig(&Config{Auth: &AuthConfig{LoginRateLimitWindowMinutes: -1}}); err == nil { + t.Fatal("ValidateConfig unexpectedly accepted a negative login_rate_limit_window_minutes") + } +} + // TestApplyAuthAndTenantDefaults_CrossTenantAccess is a regression test for the // env-binding gap: viper.AutomaticEnv has no SetEnvPrefix, so // WEKNORA_TENANT_ENABLE_CROSS_TENANT_ACCESS is never bound to the nested struct diff --git a/internal/config/config.go b/internal/config/config.go index 2408a8dcf0..1b1b943524 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -280,6 +280,13 @@ type AuthConfig struct { // tenantless creates only the identity and waits for an invitation or an // explicit self-service tenant creation. DefaultTenantMode string `yaml:"default_tenant_mode" json:"default_tenant_mode"` + // LoginRateLimitMax is the maximum number of password-login requests that + // one client IP may make during LoginRateLimitWindowMinutes. It is enabled by + // default to bound online password guessing. + LoginRateLimitMax int `yaml:"login_rate_limit_max" json:"login_rate_limit_max"` + // LoginRateLimitWindowMinutes is the rolling window for + // LoginRateLimitMax, expressed in whole minutes. + LoginRateLimitWindowMinutes int `yaml:"login_rate_limit_window_minutes" json:"login_rate_limit_window_minutes"` } // AuthRegistrationMode constants used by handlers and middleware. @@ -637,6 +644,12 @@ func ValidateConfig(cfg *Config) error { errs = append(errs, fmt.Sprintf("auth.default_tenant_mode must be %q or %q, got %q", AuthDefaultTenantModeCreatePersonal, AuthDefaultTenantModeTenantless, tenantMode)) } + if cfg.Auth.LoginRateLimitMax < 0 { + errs = append(errs, "auth.login_rate_limit_max must be >= 0") + } + if cfg.Auth.LoginRateLimitWindowMinutes < 0 { + errs = append(errs, "auth.login_rate_limit_window_minutes must be >= 0") + } } if cfg.Audit != nil && cfg.Audit.RetentionDays < 0 { @@ -850,6 +863,12 @@ func applyAuthAndTenantDefaults(cfg *Config) { if strings.TrimSpace(cfg.Auth.DefaultTenantMode) == "" { cfg.Auth.DefaultTenantMode = AuthDefaultTenantModeCreatePersonal } + if cfg.Auth.LoginRateLimitMax == 0 { + cfg.Auth.LoginRateLimitMax = 10 + } + if cfg.Auth.LoginRateLimitWindowMinutes == 0 { + cfg.Auth.LoginRateLimitWindowMinutes = 10 + } if value := strings.TrimSpace(os.Getenv("WEKNORA_TENANT_ENABLE_RBAC")); value != "" { v := strings.EqualFold(value, "true") diff --git a/internal/middleware/auth_login_ratelimit.go b/internal/middleware/auth_login_ratelimit.go new file mode 100644 index 0000000000..4a7bc63de2 --- /dev/null +++ b/internal/middleware/auth_login_ratelimit.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "net/http" + "strconv" + "sync" + "time" + + apperrors "github.com/Tencent/WeKnora/internal/errors" + "github.com/Tencent/WeKnora/internal/ratelimit" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const loginRateLimitKeyPrefix = "auth:login:ratelimit:" + +var ( + loginLimiterOnce sync.Once + loginLimiter *ratelimit.Limiter +) + +// loginRateLimiter creates one shared limiter for password logins. Redis makes +// its sliding window effective across application instances; the limiter's +// local fallback keeps standalone deployments protected when Redis is absent. +func loginRateLimiter(redisClient *redis.Client, window time.Duration) *ratelimit.Limiter { + loginLimiterOnce.Do(func() { + loginLimiter = ratelimit.New(redisClient, loginRateLimitKeyPrefix, window, "") + stopCh := make(chan struct{}) + go loginLimiter.StartCleanup(stopCh) + }) + return loginLimiter +} + +// LoginRateLimit protects POST /auth/login from online password guessing. The +// key is the resolved client IP; Router's trusted-proxy policy prevents a +// caller from bypassing the budget by forging X-Forwarded-For. +func LoginRateLimit(redisClient *redis.Client, max int, window time.Duration) gin.HandlerFunc { + limiter := loginRateLimiter(redisClient, window) + retryAfter := int(window.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + return func(c *gin.Context) { + if !limiter.Allow(c.Request.Context(), c.ClientIP(), max) { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + c.Error(&apperrors.AppError{ + Code: apperrors.ErrTooManyRequests, + Message: "too many login attempts; please retry later", + HTTPCode: http.StatusTooManyRequests, + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/internal/middleware/auth_login_ratelimit_test.go b/internal/middleware/auth_login_ratelimit_test.go new file mode 100644 index 0000000000..4db96f84b6 --- /dev/null +++ b/internal/middleware/auth_login_ratelimit_test.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestLoginRateLimitRejectsRequestsOverBudget(t *testing.T) { + gin.SetMode(gin.TestMode) + // Use a distinct max/window for this test. The package singleton is created + // only once, so this test owns the first construction in this package. + r := gin.New() + r.Use(ErrorHandler()) + r.POST("/login", LoginRateLimit(nil, 2, time.Hour), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/login", nil)) + require.Equal(t, http.StatusNoContent, w.Code) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/login", nil)) + require.Equal(t, http.StatusTooManyRequests, w.Code) + require.Equal(t, "3600", w.Header().Get("Retry-After")) +} diff --git a/internal/router/router.go b/internal/router/router.go index 4b4ddb53fb..d7fe32520e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -226,7 +226,7 @@ func NewRouter(params RouterParams) *gin.Engine { // so that sub-groups inherit it. v1.Use(rbacGuards.apiKeyAuthorizer.Middleware()) - RegisterAuthRoutes(v1, params.AuthHandler, rbacGuards) + RegisterAuthRoutes(v1, params.AuthHandler, rbacGuards, params.RedisClient, params.Config) RegisterTenantRoutes(v1, params.TenantHandler, params.TenantMemberHandler, params.TenantInvitationHandler, params.AuditLogHandler, rbacGuards) RegisterMyInvitationRoutes(v1, params.TenantInvitationHandler) RegisterKnowledgeBaseRoutes(v1, params.KBHandler, rbacGuards) diff --git a/internal/router/routes_auth_tenant.go b/internal/router/routes_auth_tenant.go index 32da3eb000..a011046859 100644 --- a/internal/router/routes_auth_tenant.go +++ b/internal/router/routes_auth_tenant.go @@ -2,9 +2,12 @@ package router import ( "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/Tencent/WeKnora/internal/config" "github.com/Tencent/WeKnora/internal/handler" "github.com/Tencent/WeKnora/internal/middleware" "github.com/Tencent/WeKnora/internal/types" @@ -177,7 +180,7 @@ func RegisterMyInvitationRoutes(r *gin.RouterGroup, invitationHandler *handler.T } // RegisterAuthRoutes registers authentication routes -func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler, g *rbacGuards) { +func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler, g *rbacGuards, redisClient *redis.Client, cfg *config.Config) { r.POST("/auth/register", handler.Register) // Share-link surfaces are unauthenticated and accept a plaintext // token from the caller; rate-limit by IP to bound brute-force / @@ -187,7 +190,16 @@ func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler, g *rba publicAuthRL := middleware.PublicAuthRateLimit() r.POST("/auth/register-by-invite", publicAuthRL, handler.RegisterByInvite) r.POST("/auth/invitations/lookup", publicAuthRL, handler.LookupInvitationByToken) - r.POST("/auth/login", handler.Login) + loginMax, loginWindow := 10, 10*time.Minute + if cfg != nil && cfg.Auth != nil { + if cfg.Auth.LoginRateLimitMax > 0 { + loginMax = cfg.Auth.LoginRateLimitMax + } + if cfg.Auth.LoginRateLimitWindowMinutes > 0 { + loginWindow = time.Duration(cfg.Auth.LoginRateLimitWindowMinutes) * time.Minute + } + } + r.POST("/auth/login", middleware.LoginRateLimit(redisClient, loginMax, loginWindow), handler.Login) r.POST("/auth/auto-setup", handler.AutoSetup) r.GET("/auth/config", handler.GetAuthConfig) r.POST("/auth/switch-tenant", handler.SwitchTenant) From d3799ca6266fe015081b523ce67ebc0021463a0f Mon Sep 17 00:00:00 2001 From: koishi514-Z <1101740081@qq.com> Date: Sun, 16 Aug 2026 17:40:25 +0800 Subject: [PATCH 2/2] fix: add login brute-force protection --- frontend/src/api/auth/index.ts | 6 +++- frontend/src/i18n/locales/en-US.ts | 2 ++ frontend/src/i18n/locales/ko-KR.ts | 2 ++ frontend/src/i18n/locales/ru-RU.ts | 2 ++ frontend/src/i18n/locales/zh-CN.ts | 2 ++ frontend/src/utils/request.ts | 2 ++ frontend/src/views/auth/Login.vue | 56 +++++++++++++++++++++++++++--- 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index ab730caa4e..e70b98acb1 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -12,6 +12,8 @@ export interface LoginRequest { export interface LoginResponse { success: boolean message?: string + status?: number + retryAfter?: string | number user?: { id: string username: string @@ -214,7 +216,9 @@ export async function login(data: LoginRequest): Promise { } catch (error: any) { return { success: false, - message: error.message || t('error.auth.loginFailed') + message: error.message || t('error.auth.loginFailed'), + status: error.status, + retryAfter: error.retryAfter, } } } diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 681177e94b..34eaba736a 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1892,6 +1892,8 @@ export default { passwordMismatch: 'Entered passwords do not match', loginError: 'Login error, please check email or password', loginErrorRetry: 'Login error, please try again later', + loginRateLimited: 'Too many login attempts. Try again in {seconds} seconds.', + loginRetryCountdown: 'Try again in {seconds}s', registerError: 'Registration error, please try again later', workspaceOnboarding: { title: 'Choose your workspace', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 08a25b5300..e36817312c 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -4495,6 +4495,8 @@ export default { passwordMismatch: '두 비밀번호가 일치하지 않습니다', loginError: '로그인 오류, 이메일 또는 비밀번호를 확인해주세요', loginErrorRetry: '로그인 오류, 나중에 다시 시도해주세요', + loginRateLimited: '로그인 시도가 너무 많습니다. {seconds}초 후에 다시 시도하세요.', + loginRetryCountdown: '{seconds}초 후 재시도', registerError: '가입 오류, 나중에 다시 시도해주세요', workspaceOnboarding: { title: '작업 공간 선택', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 5f2b0929f0..102b2f5810 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -4495,6 +4495,8 @@ export default { passwordMismatch: 'Введённые пароли не совпадают', loginError: 'Ошибка входа, пожалуйста, проверьте электронную почту или пароль', loginErrorRetry: 'Ошибка входа, пожалуйста, повторите попытку позже', + loginRateLimited: 'Слишком много попыток входа. Повторите через {seconds} сек.', + loginRetryCountdown: 'Повторите через {seconds} сек.', registerError: 'Ошибка регистрации, пожалуйста, повторите попытку позже', workspaceOnboarding: { title: 'Выберите рабочее пространство', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 6491dd6e99..e689a89004 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4497,6 +4497,8 @@ export default { passwordMismatch: '两次输入的密码不一致', loginError: '登录错误,请检查邮箱或密码', loginErrorRetry: '登录错误,请稍后重试', + loginRateLimited: '登录尝试次数过多,请在 {seconds} 秒后重试。', + loginRetryCountdown: '{seconds} 秒后重试', registerError: '注册错误,请稍后重试', workspaceOnboarding: { title: '选择你的工作空间', diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index c12ac32e98..07051c577d 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -238,9 +238,11 @@ instance.interceptors.response.use( } else if (typeof data === 'string') { errorMessage = data; } + const retryAfter = error.response.headers?.['retry-after']; return Promise.reject({ status, message: errorMessage, + retryAfter, ...(typeof data === 'object' ? data : {}) }); } diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index edd29b1e2a..a17431531b 100755 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -203,18 +203,23 @@ label-align="top"> + autocomplete="email" size="large" :disabled="loading || loginLocked" /> + autocomplete="current-password" size="large" :disabled="loading || loginLocked" @enter="handleLogin" /> - - {{ loading ? $t('auth.loggingIn') : $t('auth.login') }} + + {{ loading ? $t('auth.loggingIn') : (loginLocked ? $t('auth.loginRetryCountdown', { seconds: loginRetryAfterSeconds }) : $t('auth.login')) }} + +
{{ $t('auth.firstTime') }} @@ -418,6 +423,29 @@ const oidcProviderName = ref('') // link is visible; the actual mode is fetched from /auth/config in onMounted. // In invite_only mode the link/card are hidden. const registrationEnabled = ref(true) +const loginRetryAfterSeconds = ref(0) +let loginRetryTimer: ReturnType | undefined +const loginLocked = computed(() => loginRetryAfterSeconds.value > 0) + +const clearLoginRetryTimer = () => { + if (loginRetryTimer) { + clearInterval(loginRetryTimer) + loginRetryTimer = undefined + } +} + +const startLoginRetryCountdown = (retryAfter: string | number | undefined) => { + const seconds = Number.parseInt(String(retryAfter), 10) + loginRetryAfterSeconds.value = Number.isFinite(seconds) && seconds > 0 ? seconds : 60 + clearLoginRetryTimer() + loginRetryTimer = setInterval(() => { + loginRetryAfterSeconds.value -= 1 + if (loginRetryAfterSeconds.value <= 0) { + loginRetryAfterSeconds.value = 0 + clearLoginRetryTimer() + } + }, 1000) +} // invite-link state. When the URL carries ?token=xxx we resolve it to // the originating tenant + role and switch the form into a "register @@ -546,6 +574,7 @@ onMounted(() => { onBeforeUnmount(() => { document.removeEventListener('click', handleClickOutside) + clearLoginRetryTimer() }) const persistLoginResponse = async (response: any, skipRedirect = false) => { @@ -673,6 +702,7 @@ const acceptAndEnter = async (token: string) => { // Handle login const handleLogin = async () => { + if (loginLocked.value) return try { const valid = await formRef.value?.validate() if (valid !== true) return @@ -694,6 +724,10 @@ const handleLogin = async () => { await persistLoginResponse(response) notifyLoginSuccess(response, t, tm, formatRole, roleIcon) } else { + if (response.status === 429) { + startLoginRetryCountdown(response.retryAfter) + return + } MessagePlugin.error(response.message || t('auth.loginError')) } } catch (error: any) { @@ -1543,6 +1577,20 @@ onMounted(async () => { margin: 20px 0 16px 0; } +.login-rate-limit-notice { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 10px 12px; + color: var(--td-error-color-7); + background: var(--td-error-color-1); + border: 1px solid var(--td-error-color-3); + border-radius: 6px; + font-size: 13px; + line-height: 20px; +} + .oidc-divider { position: relative; margin: 4px 0 6px;