From 0f40488bf16be1579618ff21347e679be49bd4cb Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 5 May 2026 22:22:04 +0800 Subject: [PATCH] feat(web): add password setup page, change password, and settings tabs - First-time visitors see a password setup page instead of open access - Password persisted to SQLite (env var DBX_PASSWORD takes priority) - Add change password in settings dialog (Security tab, web only) - Split settings dialog into Editor and Security tabs - Argon2 hashing for all password storage --- Dockerfile | 4 +- crates/dbx-core/src/storage.rs | 34 ++ src-web/src/auth.rs | 90 ++++- src-web/src/main.rs | 14 +- src-web/src/state.rs | 2 +- src/App.vue | 14 +- src/components/auth/LoginPage.vue | 56 ++- .../editor/EditorSettingsDialog.vue | 342 ++++++++++++------ src/i18n/locales/en.ts | 22 ++ src/i18n/locales/zh-CN.ts | 22 ++ 10 files changed, 460 insertions(+), 140 deletions(-) diff --git a/Dockerfile b/Dockerfile index ce62f36eb..9f522afc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ FROM --platform=$BUILDPLATFORM rust:1-bookworm AS backend ARG TARGETARCH WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - python3-pip gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu \ + build-essential cmake pkg-config perl python3-pip gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu \ && pip3 install --break-system-packages ziglang \ && cargo install cargo-zigbuild \ && rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu \ @@ -45,7 +45,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ # Stage 3: Final image FROM debian:bookworm-slim ARG TARGETPLATFORM -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* COPY --from=backend /out/${TARGETPLATFORM}/dbx-web /usr/local/bin/ COPY --from=frontend /app/dist /app/static ENV DBX_STATIC_DIR=/app/static diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index c34af0293..125a06560 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -49,6 +49,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id INTEGER PRIMARY KEY CHECK (id = 1), layout_json TEXT NOT NULL )", + "CREATE TABLE IF NOT EXISTS app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + settings_json TEXT NOT NULL + )", ]; // --------------------------------------------------------------------------- @@ -182,6 +186,36 @@ impl Storage { } } +// --------------------------------------------------------------------------- +// App Settings +// --------------------------------------------------------------------------- + +impl Storage { + pub async fn save_password_hash(&self, hash: &str) -> Result<(), String> { + let json = serde_json::json!({ "password_hash": hash }).to_string(); + sqlx::query("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?)") + .bind(&json) + .execute(&self.db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn load_password_hash(&self) -> Result, String> { + let row: Option<(String,)> = sqlx::query_as("SELECT settings_json FROM app_settings WHERE id = 1") + .fetch_optional(&self.db) + .await + .map_err(|e| e.to_string())?; + match row { + Some((json,)) => { + let v: serde_json::Value = serde_json::from_str(&json).map_err(|e| e.to_string())?; + Ok(v.get("password_hash").and_then(|v| v.as_str()).map(|s| s.to_string())) + } + None => Ok(None), + } + } +} + // --------------------------------------------------------------------------- // AI Conversations // --------------------------------------------------------------------------- diff --git a/src-web/src/auth.rs b/src-web/src/auth.rs index 11476494d..d54009f08 100644 --- a/src-web/src/auth.rs +++ b/src-web/src/auth.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use axum::extract::State; use axum::http::{Request, StatusCode}; use axum::middleware::Next; @@ -15,22 +17,31 @@ pub struct LoginRequest { pub password: String, } +#[derive(Deserialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + #[derive(Serialize)] pub struct AuthCheckResponse { pub authenticated: bool, pub required: bool, + pub setup_required: bool, } const MAX_ATTEMPTS: u32 = 5; const LOCKOUT_SECS: u64 = 60; pub async fn login(State(state): State>, Json(body): Json) -> Result { - let password_hash = match &state.password_hash { - Some(h) => h, + let hash_guard = state.password_hash.read().await; + let hash_str = match hash_guard.as_deref() { + Some(h) => h.to_string(), None => { return Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response()); } }; + drop(hash_guard); // Check rate limit { @@ -47,7 +58,7 @@ pub async fn login(State(state): State>, Json(body): Json>, Json(body): Json>, Json(body): Json) -> Result { + // Only allow setup when no password is configured + if state.password_hash.read().await.is_some() { + return Err(StatusCode::FORBIDDEN); + } + + if body.password.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(body.password.as_bytes(), &salt) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .to_string(); + + // Save to database + state.app.storage.save_password_hash(&hash).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Update in-memory state + *state.password_hash.write().await = Some(hash); + + // Auto-login: create session + let token = uuid::Uuid::new_v4().to_string(); + state.sessions.write().await.insert(token.clone()); + + let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax"); + Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response()) +} + pub async fn check(State(state): State>, req: Request) -> Json { - if state.password_hash.is_none() { - return Json(AuthCheckResponse { authenticated: true, required: false }); + let has_password = state.password_hash.read().await.is_some(); + if !has_password { + return Json(AuthCheckResponse { authenticated: false, required: false, setup_required: true }); } let authenticated = match extract_session_token(&req) { Some(token) => state.sessions.read().await.contains(&token), None => false, }; - Json(AuthCheckResponse { authenticated, required: true }) + Json(AuthCheckResponse { authenticated, required: true, setup_required: false }) +} + +pub async fn change_password( + State(state): State>, + Json(body): Json, +) -> Result { + let hash_guard = state.password_hash.read().await; + let hash_str = match hash_guard.as_deref() { + Some(h) => h.to_string(), + None => return Err(StatusCode::BAD_REQUEST), + }; + drop(hash_guard); + + if body.new_password.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let parsed_hash = PasswordHash::new(&hash_str).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if Argon2::default().verify_password(body.old_password.as_bytes(), &parsed_hash).is_err() { + return Err(StatusCode::UNAUTHORIZED); + } + + let salt = SaltString::generate(&mut OsRng); + let new_hash = Argon2::default() + .hash_password(body.new_password.as_bytes(), &salt) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .to_string(); + + state.app.storage.save_password_hash(&new_hash).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + *state.password_hash.write().await = Some(new_hash); + + Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response()) } pub async fn logout(State(state): State>, req: Request) -> Response { @@ -111,7 +185,7 @@ pub async fn auth_middleware( next: Next, ) -> Response { // No password set — allow everything - if state.password_hash.is_none() { + if state.password_hash.read().await.is_none() { return next.run(req).await; } diff --git a/src-web/src/main.rs b/src-web/src/main.rs index 843fadfea..4c22e466d 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -46,16 +46,18 @@ async fn main() { Arc::new(AppState::new(storage)) }; - // Password hash - let password_hash = std::env::var("DBX_PASSWORD").ok().map(|pw| { + // Password hash: env var takes priority, then database + let password_hash = if let Some(pw) = std::env::var("DBX_PASSWORD").ok() { let salt = SaltString::generate(&mut OsRng); - Argon2::default().hash_password(pw.as_bytes(), &salt).expect("Failed to hash password").to_string() - }); + Some(Argon2::default().hash_password(pw.as_bytes(), &salt).expect("Failed to hash password").to_string()) + } else { + app_state.storage.load_password_hash().await.unwrap_or(None) + }; let web_state = Arc::new(WebState { app: app_state, data_dir, - password_hash, + password_hash: RwLock::new(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 }), @@ -69,6 +71,8 @@ async fn main() { // Auth .route("/auth/login", post(auth::login)) .route("/auth/check", get(auth::check)) + .route("/auth/setup", post(auth::setup)) + .route("/auth/change-password", post(auth::change_password)) .route("/auth/logout", post(auth::logout)) // Connection .route("/connection/test", post(routes::connection::test_connection)) diff --git a/src-web/src/state.rs b/src-web/src/state.rs index 3a67a0bdb..5981c64d7 100644 --- a/src-web/src/state.rs +++ b/src-web/src/state.rs @@ -12,7 +12,7 @@ pub struct LoginRateLimit { pub struct WebState { pub app: Arc, pub data_dir: PathBuf, - pub password_hash: Option, + pub password_hash: RwLock>, pub sessions: RwLock>, pub sse_channels: RwLock>>, pub login_rate_limit: Mutex, diff --git a/src/App.vue b/src/App.vue index 53ea092b1..3c03628c1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -59,6 +59,7 @@ const { setupFileDrop } = useFileDrop(); const isDesktop = isTauriRuntime(); const needsAuth = ref(!isDesktop); const authenticated = ref(isDesktop); +const setupRequired = ref(false); const showConnectionDialog = ref(false); const showSettingsDialog = ref(false); @@ -296,6 +297,8 @@ function handleKeydown(e: KeyboardEvent) { function onLoginSuccess() { authenticated.value = true; + setupRequired.value = false; + needsAuth.value = true; window.history.replaceState(null, "", "/"); initApp(); } @@ -336,13 +339,14 @@ onMounted(async () => { const data = await res.json(); needsAuth.value = data.required; authenticated.value = data.authenticated; + setupRequired.value = data.setup_required; } catch { /* server unreachable */ } if (needsAuth.value && !authenticated.value) { history.replaceState(null, "", "/login"); } - if (!needsAuth.value || authenticated.value) initApp(); + if (!setupRequired.value && (!needsAuth.value || authenticated.value)) initApp(); api .getAppVersion() .then((v) => { @@ -369,8 +373,12 @@ onUnmounted(() => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 61d2eccfe..aae68e752 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -2,6 +2,26 @@ export default { app: { name: "DBX", }, + auth: { + setupTitle: "Set up access password", + setupDescription: "Set a password to protect your instance", + loginDescription: "Database management tool", + newPassword: "Enter new password", + confirmPassword: "Confirm password", + enterPassword: "Enter access password", + passwordMismatch: "Passwords do not match", + setPassword: "Set Password", + login: "Login", + processing: "Processing...", + loginFailed: "Incorrect password", + connectFailed: "Connection failed", + changePassword: "Change Password", + oldPassword: "Current password", + oldPasswordWrong: "Current password is incorrect", + passwordChanged: "Password changed successfully", + changePasswordFailed: "Failed to change password", + changePasswordDescription: "Enter your current password and choose a new one", + }, toolbar: { newConnection: "New Connection", newQuery: "New Query", @@ -625,6 +645,8 @@ export default { }, settings: { title: "Settings", + editorTab: "Editor", + securityTab: "Security", fontFamily: "Font Family", selectFont: "Select font...", fontSize: "Font Size", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9eff01f4f..a2885f03c 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -2,6 +2,26 @@ export default { app: { name: "DBX", }, + auth: { + setupTitle: "设置访问密码", + setupDescription: "设置密码以保护您的实例", + loginDescription: "数据库管理工具", + newPassword: "输入新密码", + confirmPassword: "确认密码", + enterPassword: "请输入访问密码", + passwordMismatch: "两次输入的密码不一致", + setPassword: "设置密码", + login: "登录", + processing: "处理中...", + loginFailed: "密码错误", + connectFailed: "连接失败", + changePassword: "修改密码", + oldPassword: "当前密码", + oldPasswordWrong: "当前密码不正确", + passwordChanged: "密码修改成功", + changePasswordFailed: "密码修改失败", + changePasswordDescription: "输入当前密码并设置新密码", + }, toolbar: { newConnection: "新建连接", newQuery: "新建查询", @@ -617,6 +637,8 @@ export default { }, settings: { title: "设置", + editorTab: "编辑器", + securityTab: "安全", fontFamily: "字体", selectFont: "选择字体...", fontSize: "字号",