From c208ebe59b0ba50c9adbc2ec3833c0c0d4ffdd83 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 26 Feb 2026 18:47:20 -0500 Subject: [PATCH] feat: implement real admin authentication and password management - Added 'users' table to database with bcrypt hashing. - Refactored login to verify against the database. - Implemented 'Security' section in settings to allow changing the admin password. - Initialized system with default user 'admin' and password 'admin'. --- Cargo.lock | 43 +++++++++++++++++ Cargo.toml | 1 + src/dashboard/mod.rs | 92 +++++++++++++++++++++++++++++++++---- src/database/mod.rs | 32 +++++++++++++ static/js/pages/settings.js | 60 ++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80072ca1..52499ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -352,6 +365,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" version = "1.12.1" @@ -429,6 +452,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "colored" version = "3.1.1" @@ -1428,6 +1461,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.3" @@ -1571,6 +1613,7 @@ dependencies = [ "axum", "axum-extra", "base64 0.21.7", + "bcrypt", "chrono", "config", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index e2366f34..3d341e90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ mime_guess = "2.0" # ========== Error Handling & Utilities ========== anyhow = "1.0" thiserror = "1.0" +bcrypt = "0.15" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } futures = "0.3" diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 9a9b4357..9b8c5507 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -63,6 +63,7 @@ pub fn router(state: AppState) -> Router { // API endpoints .route("/api/auth/login", post(handle_login)) .route("/api/auth/status", get(handle_auth_status)) + .route("/api/auth/change-password", post(handle_change_password)) .route("/api/usage/summary", get(handle_usage_summary)) .route("/api/usage/time-series", get(handle_time_series)) .route("/api/usage/clients", get(handle_clients_usage)) @@ -146,17 +147,45 @@ async fn handle_websocket_message(text: &str, state: &DashboardState) { } // Authentication handlers -async fn handle_login(State(_state): State) -> Json> { - // Simple authentication for demo - // In production, this would validate credentials against a database - Json(ApiResponse::success(serde_json::json!({ - "token": "demo-token-123456", - "user": { - "username": "admin", - "name": "Administrator", - "role": "Super Admin" +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +async fn handle_login( + State(state): State, + Json(payload): Json, +) -> Json> { + let pool = &state.app_state.db_pool; + + let user_result = sqlx::query("SELECT username, password_hash, role FROM users WHERE username = ?") + .bind(&payload.username) + .fetch_optional(pool) + .await; + + match user_result { + Ok(Some(row)) => { + let hash = row.get::("password_hash"); + if bcrypt::verify(&payload.password, &hash).unwrap_or(false) { + Json(ApiResponse::success(serde_json::json!({ + "token": format!("session-{}", uuid::Uuid::new_v4()), + "user": { + "username": row.get::("username"), + "name": "Administrator", + "role": row.get::("role") + } + }))) + } else { + Json(ApiResponse::error("Invalid username or password".to_string())) + } } - }))) + Ok(None) => Json(ApiResponse::error("Invalid username or password".to_string())), + Err(e) => { + warn!("Database error during login: {}", e); + Json(ApiResponse::error("Login failed due to system error".to_string())) + } + } } async fn handle_auth_status(State(_state): State) -> Json> { @@ -170,6 +199,49 @@ async fn handle_auth_status(State(_state): State) -> Json, + Json(payload): Json, +) -> Json> { + let pool = &state.app_state.db_pool; + + // For now, always change 'admin' user + let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = 'admin'") + .fetch_one(pool) + .await; + + match user_result { + Ok(row) => { + let hash = row.get::("password_hash"); + if bcrypt::verify(&payload.current_password, &hash).unwrap_or(false) { + let new_hash = match bcrypt::hash(&payload.new_password, 12) { + Ok(h) => h, + Err(_) => return Json(ApiResponse::error("Failed to hash new password".to_string())), + }; + + let update_result = sqlx::query("UPDATE users SET password_hash = ? WHERE username = 'admin'") + .bind(new_hash) + .execute(pool) + .await; + + match update_result { + Ok(_) => Json(ApiResponse::success(serde_json::json!({ "message": "Password updated successfully" }))), + Err(e) => Json(ApiResponse::error(format!("Failed to update database: {}", e))), + } + } else { + Json(ApiResponse::error("Current password incorrect".to_string())) + } + } + Err(e) => Json(ApiResponse::error(format!("User not found: {}", e))), + } +} + // Usage handlers async fn handle_usage_summary(State(state): State) -> Json> { let pool = &state.app_state.db_pool; diff --git a/src/database/mod.rs b/src/database/mod.rs index 55e526b8..4a0adf70 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -115,6 +115,38 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { .execute(pool) .await?; + // Create users table for dashboard access + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT DEFAULT 'admin', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "# + ) + .execute(pool) + .await?; + + // Insert default admin user if none exists (default password: admin) + let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + + if user_count.0 == 0 { + // 'admin' hashed with default cost (12) + let default_admin_hash = bcrypt::hash("admin", 12).unwrap(); + sqlx::query( + "INSERT INTO users (username, password_hash, role) VALUES ('admin', ?, 'admin')" + ) + .bind(default_admin_hash) + .execute(pool) + .await?; + info!("Created default admin user with password 'admin'"); + } + // Create indices sqlx::query( "CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id)" diff --git a/static/js/pages/settings.js b/static/js/pages/settings.js index fe45d585..d191e5f3 100644 --- a/static/js/pages/settings.js +++ b/static/js/pages/settings.js @@ -55,6 +55,30 @@ class SettingsPage { +
+
+

Security

+
+
+

Change the administrator password for the dashboard.

+
+ + +
+
+ + +
+
+ + +
+ +
+
+

Database & Registry

@@ -115,6 +139,42 @@ class SettingsPage { } } + async changePassword() { + const currentPassword = document.getElementById('current-password').value; + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('confirm-password').value; + + if (!currentPassword || !newPassword) { + window.authManager.showToast('Please fill in all password fields', 'error'); + return; + } + + if (newPassword !== confirmPassword) { + window.authManager.showToast('New passwords do not match', 'error'); + return; + } + + if (newPassword.length < 4) { + window.authManager.showToast('New password must be at least 4 characters', 'error'); + return; + } + + try { + await window.api.post('/auth/change-password', { + current_password: currentPassword, + new_password: newPassword + }); + window.authManager.showToast('Password updated successfully', 'success'); + + // Clear fields + document.getElementById('current-password').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + } catch (error) { + window.authManager.showToast(error.message, 'error'); + } + } + setupEventListeners() { // ... }