Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/RBAC说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 关闭清理
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/api/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface LoginRequest {
export interface LoginResponse {
success: boolean
message?: string
status?: number
retryAfter?: string | number
user?: {
id: string
username: string
Expand Down Expand Up @@ -214,7 +216,9 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
} 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,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,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',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4495,6 +4495,8 @@ export default {
passwordMismatch: '두 비밀번호가 일치하지 않습니다',
loginError: '로그인 오류, 이메일 또는 비밀번호를 확인해주세요',
loginErrorRetry: '로그인 오류, 나중에 다시 시도해주세요',
loginRateLimited: '로그인 시도가 너무 많습니다. {seconds}초 후에 다시 시도하세요.',
loginRetryCountdown: '{seconds}초 후 재시도',
registerError: '가입 오류, 나중에 다시 시도해주세요',
workspaceOnboarding: {
title: '작업 공간 선택',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4495,6 +4495,8 @@ export default {
passwordMismatch: 'Введённые пароли не совпадают',
loginError: 'Ошибка входа, пожалуйста, проверьте электронную почту или пароль',
loginErrorRetry: 'Ошибка входа, пожалуйста, повторите попытку позже',
loginRateLimited: 'Слишком много попыток входа. Повторите через {seconds} сек.',
loginRetryCountdown: 'Повторите через {seconds} сек.',
registerError: 'Ошибка регистрации, пожалуйста, повторите попытку позже',
workspaceOnboarding: {
title: 'Выберите рабочее пространство',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4497,6 +4497,8 @@ export default {
passwordMismatch: '两次输入的密码不一致',
loginError: '登录错误,请检查邮箱或密码',
loginErrorRetry: '登录错误,请稍后重试',
loginRateLimited: '登录尝试次数过多,请在 {seconds} 秒后重试。',
loginRetryCountdown: '{seconds} 秒后重试',
registerError: '注册错误,请稍后重试',
workspaceOnboarding: {
title: '选择你的工作空间',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : {})
});
}
Expand Down
56 changes: 52 additions & 4 deletions frontend/src/views/auth/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -203,18 +203,23 @@
label-align="top">
<t-form-item :label="$t('auth.email')" name="email">
<t-input v-model="formData.email" :placeholder="$t('auth.emailPlaceholder')" type="text"
autocomplete="email" size="large" :disabled="loading" />
autocomplete="email" size="large" :disabled="loading || loginLocked" />
</t-form-item>

<t-form-item :label="$t('auth.password')" name="password">
<t-input v-model="formData.password" :placeholder="$t('auth.passwordPlaceholder')" type="password"
autocomplete="current-password" size="large" :disabled="loading" @enter="handleLogin" />
autocomplete="current-password" size="large" :disabled="loading || loginLocked" @enter="handleLogin" />
</t-form-item>

<t-button type="submit" theme="primary" size="large" block :loading="loading" class="submit-button">
{{ loading ? $t('auth.loggingIn') : $t('auth.login') }}
<t-button type="submit" theme="primary" size="large" block :loading="loading" :disabled="loginLocked" class="submit-button">
{{ loading ? $t('auth.loggingIn') : (loginLocked ? $t('auth.loginRetryCountdown', { seconds: loginRetryAfterSeconds }) : $t('auth.login')) }}
</t-button>

<div v-if="loginLocked" class="login-rate-limit-notice" role="alert" aria-live="polite">
<t-icon name="time" />
<span>{{ $t('auth.loginRateLimited', { seconds: loginRetryAfterSeconds }) }}</span>
</div>

<div class="register-cta" v-if="registrationEnabled">
<div class="register-cta__divider">
<span>{{ $t('auth.firstTime') }}</span>
Expand Down Expand Up @@ -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<typeof setInterval> | 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
Expand Down Expand Up @@ -546,6 +574,7 @@ onMounted(() => {

onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
clearLoginRetryTimer()
})

const persistLoginResponse = async (response: any, skipRedirect = false) => {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions internal/config/auth_legacy_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
56 changes: 56 additions & 0 deletions internal/middleware/auth_login_ratelimit.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
33 changes: 33 additions & 0 deletions internal/middleware/auth_login_ratelimit_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
2 changes: 1 addition & 1 deletion internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions internal/router/routes_auth_tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 /
Expand All @@ -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)
Expand Down