diff --git a/package.json b/package.json index 3b8c9484b0..280a7d6257 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "dev:tauri": "tauri dev", "dev:web": "vite --port 5173 --mode web", - "dev:backend": "RUST_LOG=info cargo watch -x 'run -p dbx-web'", + "dev:backend": "RUST_LOG=${RUST_LOG:-info} DBX_PASSWORD=${DBX_PASSWORD:-test} cargo watch -x 'run -p dbx-web'", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "tauri": "tauri" diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000..011a87957a Binary files /dev/null and b/public/logo.png differ diff --git a/src-web/src/auth.rs b/src-web/src/auth.rs index 5205591108..1c00051686 100644 --- a/src-web/src/auth.rs +++ b/src-web/src/auth.rs @@ -21,6 +21,9 @@ pub struct AuthCheckResponse { pub required: bool, } +const MAX_ATTEMPTS: u32 = 5; +const LOCKOUT_SECS: u64 = 60; + pub async fn login( State(state): State>, Json(body): Json, @@ -36,14 +39,41 @@ pub async fn login( } }; + // Check rate limit + { + let rl = state.login_rate_limit.lock().await; + if let Some(locked_until) = rl.locked_until { + if locked_until > std::time::Instant::now() { + let remaining = (locked_until - std::time::Instant::now()).as_secs(); + return Ok(( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({"error": format!("请 {remaining} 秒后再试")})), + ).into_response()); + } + } + } + let mut hasher = Sha256::new(); hasher.update(body.password.as_bytes()); let hash = hex::encode(hasher.finalize()); if hash != *password_hash { + let mut rl = state.login_rate_limit.lock().await; + rl.fail_count += 1; + if rl.fail_count >= MAX_ATTEMPTS { + rl.locked_until = Some(std::time::Instant::now() + std::time::Duration::from_secs(LOCKOUT_SECS)); + rl.fail_count = 0; + } return Err(StatusCode::UNAUTHORIZED); } + // Success — reset rate limit + { + let mut rl = state.login_rate_limit.lock().await; + rl.fail_count = 0; + rl.locked_until = None; + } + let token = uuid::Uuid::new_v4().to_string(); state.sessions.write().await.insert(token.clone()); diff --git a/src-web/src/main.rs b/src-web/src/main.rs index c7c414d08c..c307ad7d8f 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -61,6 +61,10 @@ async fn main() { password_hash, sessions: RwLock::new(HashSet::new()), sse_channels: RwLock::new(HashMap::new()), + login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { + fail_count: 0, + locked_until: None, + }), }); // CORS @@ -146,12 +150,12 @@ async fn main() { .route("/update/check", get(routes::update::check_for_updates)) // Layout .route("/layout/sidebar", post(routes::layout::save_sidebar_layout).get(routes::layout::load_sidebar_layout)) + .layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware)) .with_state(web_state.clone()); - // Build app with auth middleware + // Build app let mut app = Router::new() .nest("/api", api) - .layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware)) .layer(cors); // Static file serving diff --git a/src-web/src/state.rs b/src-web/src/state.rs index 94dbb30f96..3a67a0bdbc 100644 --- a/src-web/src/state.rs +++ b/src-web/src/state.rs @@ -2,7 +2,12 @@ use dbx_core::connection::AppState; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::{broadcast, RwLock}; +use tokio::sync::{broadcast, Mutex, RwLock}; + +pub struct LoginRateLimit { + pub fail_count: u32, + pub locked_until: Option, +} pub struct WebState { pub app: Arc, @@ -10,4 +15,5 @@ pub struct WebState { pub password_hash: Option, pub sessions: RwLock>, pub sse_channels: RwLock>>, + pub login_rate_limit: Mutex, } diff --git a/src/App.vue b/src/App.vue index 7c99db2724..0e75a357eb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -201,6 +201,12 @@ function handleKeydown(e: KeyboardEvent) { } } +function onLoginSuccess() { + authenticated.value = true; + window.history.replaceState(null, "", "/"); + initApp(); +} + function initApp() { connectionStore.initFromDisk().then(() => { reconnectRestoredTabs(); @@ -235,6 +241,9 @@ onMounted(async () => { needsAuth.value = data.required; authenticated.value = data.authenticated; } catch { /* server unreachable */ } + if (needsAuth.value && !authenticated.value) { + history.replaceState(null, "", "/login"); + } if (!needsAuth.value || authenticated.value) initApp(); api.checkForUpdates().then((info) => { appVersion.value = info.current_version; }).catch(() => {}); return; @@ -252,8 +261,9 @@ onUnmounted(() => { window.removeEventListener("keydown", handleKeydown, true);