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'.
This commit is contained in:
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -313,6 +313,19 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -352,6 +365,16 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -429,6 +452,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "colored"
|
name = "colored"
|
||||||
version = "3.1.1"
|
version = "3.1.1"
|
||||||
@@ -1428,6 +1461,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "insta"
|
name = "insta"
|
||||||
version = "1.46.3"
|
version = "1.46.3"
|
||||||
@@ -1571,6 +1613,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ mime_guess = "2.0"
|
|||||||
# ========== Error Handling & Utilities ==========
|
# ========== Error Handling & Utilities ==========
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
bcrypt = "0.15"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// API endpoints
|
// API endpoints
|
||||||
.route("/api/auth/login", post(handle_login))
|
.route("/api/auth/login", post(handle_login))
|
||||||
.route("/api/auth/status", get(handle_auth_status))
|
.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/summary", get(handle_usage_summary))
|
||||||
.route("/api/usage/time-series", get(handle_time_series))
|
.route("/api/usage/time-series", get(handle_time_series))
|
||||||
.route("/api/usage/clients", get(handle_clients_usage))
|
.route("/api/usage/clients", get(handle_clients_usage))
|
||||||
@@ -146,17 +147,45 @@ async fn handle_websocket_message(text: &str, state: &DashboardState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authentication handlers
|
// Authentication handlers
|
||||||
async fn handle_login(State(_state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
#[derive(Deserialize)]
|
||||||
// Simple authentication for demo
|
struct LoginRequest {
|
||||||
// In production, this would validate credentials against a database
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_login(
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
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::<String, _>("password_hash");
|
||||||
|
if bcrypt::verify(&payload.password, &hash).unwrap_or(false) {
|
||||||
Json(ApiResponse::success(serde_json::json!({
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
"token": "demo-token-123456",
|
"token": format!("session-{}", uuid::Uuid::new_v4()),
|
||||||
"user": {
|
"user": {
|
||||||
"username": "admin",
|
"username": row.get::<String, _>("username"),
|
||||||
"name": "Administrator",
|
"name": "Administrator",
|
||||||
"role": "Super Admin"
|
"role": row.get::<String, _>("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<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
async fn handle_auth_status(State(_state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
@@ -170,6 +199,49 @@ async fn handle_auth_status(State(_state): State<DashboardState>) -> Json<ApiRes
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChangePasswordRequest {
|
||||||
|
current_password: String,
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_change_password(
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
Json(payload): Json<ChangePasswordRequest>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
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::<String, _>("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
|
// Usage handlers
|
||||||
async fn handle_usage_summary(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
async fn handle_usage_summary(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|||||||
@@ -115,6 +115,38 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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
|
// Create indices
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id)"
|
"CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id)"
|
||||||
|
|||||||
@@ -55,6 +55,30 @@ class SettingsPage {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="fas fa-lock"></i> Security</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="margin-bottom: 1rem; font-size: 0.875rem; color: var(--fg3);">Change the administrator password for the dashboard.</p>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="current-password">Current Password</label>
|
||||||
|
<input type="password" id="current-password" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="new-password">New Password</label>
|
||||||
|
<input type="password" id="new-password" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="confirm-password">Confirm New Password</label>
|
||||||
|
<input type="password" id="confirm-password" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="window.settingsPage.changePassword()">
|
||||||
|
<i class="fas fa-key"></i> Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title"><i class="fas fa-database"></i> Database & Registry</h3>
|
<h3 class="card-title"><i class="fas fa-database"></i> Database & Registry</h3>
|
||||||
@@ -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() {
|
setupEventListeners() {
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user