Add user management system with JWT authentication

- Database schema: users, permissions, client_access tables
- Auth: JWT tokens with Argon2 password hashing
- API: login, user CRUD, permission management
- Dashboard: login required, admin Users tab
- Auto-creates initial admin user on first run
This commit is contained in:
2025-12-29 20:57:30 -07:00
parent 743b73dfe7
commit 3fc4e1f96a
13 changed files with 2354 additions and 70 deletions

View File

@@ -18,12 +18,14 @@ pub mod proto {
use anyhow::Result;
use axum::{
Router,
routing::{get, post, delete},
extract::{Path, State, Json, Query},
routing::{get, post, put, delete},
extract::{Path, State, Json, Query, Request},
response::{Html, IntoResponse},
http::StatusCode,
middleware::{self, Next},
};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_http::services::ServeDir;
@@ -32,6 +34,7 @@ use tracing_subscriber::FmtSubscriber;
use serde::Deserialize;
use support_codes::{SupportCodeManager, CreateCodeRequest, SupportCode, CodeValidation};
use auth::{JwtConfig, hash_password, generate_random_password};
/// Application state
#[derive(Clone)]
@@ -39,6 +42,17 @@ pub struct AppState {
sessions: session::SessionManager,
support_codes: SupportCodeManager,
db: Option<db::Database>,
pub jwt_config: Arc<JwtConfig>,
}
/// Middleware to inject JWT config into request extensions
async fn auth_layer(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> impl IntoResponse {
request.extensions_mut().insert(state.jwt_config.clone());
next.run(request).await
}
#[tokio::main]
@@ -58,6 +72,17 @@ async fn main() -> Result<()> {
let listen_addr = std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3002".to_string());
info!("Loaded configuration, listening on {}", listen_addr);
// JWT configuration
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
tracing::warn!("JWT_SECRET not set, using default (INSECURE for production!)");
"guruconnect-dev-secret-change-me-in-production".to_string()
});
let jwt_expiry_hours = std::env::var("JWT_EXPIRY_HOURS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(24i64);
let jwt_config = Arc::new(JwtConfig::new(jwt_secret, jwt_expiry_hours));
// Initialize database if configured
let database = if let Some(ref db_url) = config.database_url {
match db::Database::connect(db_url, config.database_max_connections).await {
@@ -79,6 +104,47 @@ async fn main() -> Result<()> {
None
};
// Create initial admin user if no users exist
if let Some(ref db) = database {
match db::count_users(db.pool()).await {
Ok(0) => {
info!("No users found, creating initial admin user...");
let password = generate_random_password(16);
let password_hash = hash_password(&password)?;
match db::create_user(db.pool(), "admin", &password_hash, None, "admin").await {
Ok(user) => {
// Set admin permissions
let perms = vec![
"view".to_string(),
"control".to_string(),
"transfer".to_string(),
"manage_users".to_string(),
"manage_clients".to_string(),
];
let _ = db::set_user_permissions(db.pool(), user.id, &perms).await;
info!("========================================");
info!(" INITIAL ADMIN USER CREATED");
info!(" Username: admin");
info!(" Password: {}", password);
info!(" (Change this password after first login!)");
info!("========================================");
}
Err(e) => {
tracing::error!("Failed to create initial admin user: {}", e);
}
}
}
Ok(count) => {
info!("{} user(s) in database", count);
}
Err(e) => {
tracing::warn!("Could not check user count: {}", e);
}
}
}
// Create session manager
let sessions = session::SessionManager::new();
@@ -102,23 +168,40 @@ async fn main() -> Result<()> {
sessions,
support_codes: SupportCodeManager::new(),
db: database,
jwt_config,
};
// Build router
let app = Router::new()
// Health check
// Health check (no auth required)
.route("/health", get(health))
// Auth endpoints (no auth required for login)
.route("/api/auth/login", post(api::auth::login))
// Auth endpoints (auth required)
.route("/api/auth/me", get(api::auth::get_me))
.route("/api/auth/change-password", post(api::auth::change_password))
// User management (admin only)
.route("/api/users", get(api::users::list_users))
.route("/api/users", post(api::users::create_user))
.route("/api/users/:id", get(api::users::get_user))
.route("/api/users/:id", put(api::users::update_user))
.route("/api/users/:id", delete(api::users::delete_user))
.route("/api/users/:id/permissions", put(api::users::set_permissions))
.route("/api/users/:id/clients", put(api::users::set_client_access))
// Portal API - Support codes
.route("/api/codes", post(create_code))
.route("/api/codes", get(list_codes))
.route("/api/codes/:code/validate", get(validate_code))
.route("/api/codes/:code/cancel", post(cancel_code))
// WebSocket endpoints
.route("/ws/agent", get(relay::agent_ws_handler))
.route("/ws/viewer", get(relay::viewer_ws_handler))
// REST API - Sessions
.route("/api/sessions", get(list_sessions))
.route("/api/sessions/:id", get(get_session))
@@ -129,17 +212,19 @@ async fn main() -> Result<()> {
.route("/api/machines/:agent_id", get(get_machine))
.route("/api/machines/:agent_id", delete(delete_machine))
.route("/api/machines/:agent_id/history", get(get_machine_history))
// HTML page routes (clean URLs)
.route("/login", get(serve_login))
.route("/dashboard", get(serve_dashboard))
.route("/users", get(serve_users))
// State and middleware
.with_state(state.clone())
.layer(middleware::from_fn_with_state(state, auth_layer))
// State
.with_state(state)
// Serve static files for portal (fallback)
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
// Middleware
.layer(TraceLayer::new_for_http())
.layer(
@@ -380,3 +465,10 @@ async fn serve_dashboard() -> impl IntoResponse {
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_users() -> impl IntoResponse {
match tokio::fs::read_to_string("static/users.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}