From 3fc4e1f96a33a0a86f3d9f04a37a6c89c7e0e62f Mon Sep 17 00:00:00 2001
From: Mike Swanson
Date: Mon, 29 Dec 2025 20:57:30 -0700
Subject: [PATCH] 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
---
server/migrations/002_user_management.sql | 44 ++
server/src/api/auth.rs | 317 ++++++++++++
server/src/api/mod.rs | 3 +
server/src/api/users.rs | 592 +++++++++++++++++++++
server/src/auth/jwt.rs | 140 +++++
server/src/auth/mod.rs | 131 ++++-
server/src/auth/password.rs | 57 ++
server/src/db/mod.rs | 2 +
server/src/db/users.rs | 283 ++++++++++
server/src/main.rs | 114 +++-
server/static/dashboard.html | 72 ++-
server/static/login.html | 67 ++-
server/static/users.html | 602 ++++++++++++++++++++++
13 files changed, 2354 insertions(+), 70 deletions(-)
create mode 100644 server/migrations/002_user_management.sql
create mode 100644 server/src/api/auth.rs
create mode 100644 server/src/api/users.rs
create mode 100644 server/src/auth/jwt.rs
create mode 100644 server/src/auth/password.rs
create mode 100644 server/src/db/users.rs
create mode 100644 server/static/users.html
diff --git a/server/migrations/002_user_management.sql b/server/migrations/002_user_management.sql
new file mode 100644
index 0000000..e3c7576
--- /dev/null
+++ b/server/migrations/002_user_management.sql
@@ -0,0 +1,44 @@
+-- GuruConnect User Management Schema
+-- User authentication, roles, and per-client access control
+
+-- Users table
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ username VARCHAR(64) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ email VARCHAR(255),
+ role VARCHAR(32) NOT NULL DEFAULT 'viewer',
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_login TIMESTAMPTZ
+);
+
+-- Granular permissions (what actions a user can perform)
+CREATE TABLE user_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ permission VARCHAR(64) NOT NULL,
+ UNIQUE(user_id, permission)
+);
+
+-- Per-client access (which machines a user can access)
+-- No entries = access to all clients (for admins)
+CREATE TABLE user_client_access (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ client_id UUID NOT NULL REFERENCES connect_machines(id) ON DELETE CASCADE,
+ UNIQUE(user_id, client_id)
+);
+
+-- Indexes
+CREATE INDEX idx_users_username ON users(username);
+CREATE INDEX idx_users_enabled ON users(enabled);
+CREATE INDEX idx_user_permissions_user ON user_permissions(user_id);
+CREATE INDEX idx_user_client_access_user ON user_client_access(user_id);
+
+-- Trigger for updated_at
+CREATE TRIGGER update_users_updated_at
+ BEFORE UPDATE ON users
+ FOR EACH ROW
+ EXECUTE FUNCTION update_connect_updated_at();
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
new file mode 100644
index 0000000..e39fe32
--- /dev/null
+++ b/server/src/api/auth.rs
@@ -0,0 +1,317 @@
+//! Authentication API endpoints
+
+use axum::{
+ extract::State,
+ http::StatusCode,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+
+use crate::auth::{
+ verify_password, AuthenticatedUser, JwtConfig,
+};
+use crate::db;
+use crate::AppState;
+
+/// Login request
+#[derive(Debug, Deserialize)]
+pub struct LoginRequest {
+ pub username: String,
+ pub password: String,
+}
+
+/// Login response
+#[derive(Debug, Serialize)]
+pub struct LoginResponse {
+ pub token: String,
+ pub user: UserResponse,
+}
+
+/// User info in response
+#[derive(Debug, Serialize)]
+pub struct UserResponse {
+ pub id: String,
+ pub username: String,
+ pub email: Option,
+ pub role: String,
+ pub permissions: Vec,
+}
+
+/// Error response
+#[derive(Debug, Serialize)]
+pub struct ErrorResponse {
+ pub error: String,
+}
+
+/// POST /api/auth/login
+pub async fn login(
+ State(state): State,
+ Json(request): Json,
+) -> Result, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ // Get user by username
+ let user = db::get_user_by_username(db.pool(), &request.username)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error during login: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Internal server error".to_string(),
+ }),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::UNAUTHORIZED,
+ Json(ErrorResponse {
+ error: "Invalid username or password".to_string(),
+ }),
+ )
+ })?;
+
+ // Check if user is enabled
+ if !user.enabled {
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ Json(ErrorResponse {
+ error: "Account is disabled".to_string(),
+ }),
+ ));
+ }
+
+ // Verify password
+ let password_valid = verify_password(&request.password, &user.password_hash)
+ .map_err(|e| {
+ tracing::error!("Password verification error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Internal server error".to_string(),
+ }),
+ )
+ })?;
+
+ if !password_valid {
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ Json(ErrorResponse {
+ error: "Invalid username or password".to_string(),
+ }),
+ ));
+ }
+
+ // Get user permissions
+ let permissions = db::get_user_permissions(db.pool(), user.id)
+ .await
+ .unwrap_or_default();
+
+ // Update last login
+ let _ = db::update_last_login(db.pool(), user.id).await;
+
+ // Create JWT token
+ let token = state.jwt_config.create_token(
+ user.id,
+ &user.username,
+ &user.role,
+ permissions.clone(),
+ )
+ .map_err(|e| {
+ tracing::error!("Token creation error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to create token".to_string(),
+ }),
+ )
+ })?;
+
+ tracing::info!("User {} logged in successfully", user.username);
+
+ Ok(Json(LoginResponse {
+ token,
+ user: UserResponse {
+ id: user.id.to_string(),
+ username: user.username,
+ email: user.email,
+ role: user.role,
+ permissions,
+ },
+ }))
+}
+
+/// GET /api/auth/me - Get current user info
+pub async fn get_me(
+ State(state): State,
+ user: AuthenticatedUser,
+) -> Result, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ let db_user = db::get_user_by_id(db.pool(), user_id)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Internal server error".to_string(),
+ }),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "User not found".to_string(),
+ }),
+ )
+ })?;
+
+ let permissions = db::get_user_permissions(db.pool(), db_user.id)
+ .await
+ .unwrap_or_default();
+
+ Ok(Json(UserResponse {
+ id: db_user.id.to_string(),
+ username: db_user.username,
+ email: db_user.email,
+ role: db_user.role,
+ permissions,
+ }))
+}
+
+/// Change password request
+#[derive(Debug, Deserialize)]
+pub struct ChangePasswordRequest {
+ pub current_password: String,
+ pub new_password: String,
+}
+
+/// POST /api/auth/change-password
+pub async fn change_password(
+ State(state): State,
+ user: AuthenticatedUser,
+ Json(request): Json,
+) -> Result)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ // Get current user
+ let db_user = db::get_user_by_id(db.pool(), user_id)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Internal server error".to_string(),
+ }),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "User not found".to_string(),
+ }),
+ )
+ })?;
+
+ // Verify current password
+ let password_valid = verify_password(&request.current_password, &db_user.password_hash)
+ .map_err(|e| {
+ tracing::error!("Password verification error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Internal server error".to_string(),
+ }),
+ )
+ })?;
+
+ if !password_valid {
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ Json(ErrorResponse {
+ error: "Current password is incorrect".to_string(),
+ }),
+ ));
+ }
+
+ // Validate new password
+ if request.new_password.len() < 8 {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Password must be at least 8 characters".to_string(),
+ }),
+ ));
+ }
+
+ // Hash new password
+ let new_hash = crate::auth::hash_password(&request.new_password)
+ .map_err(|e| {
+ tracing::error!("Password hashing error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to hash password".to_string(),
+ }),
+ )
+ })?;
+
+ // Update password
+ db::update_user_password(db.pool(), user_id, &new_hash)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to update password".to_string(),
+ }),
+ )
+ })?;
+
+ tracing::info!("User {} changed their password", user.username);
+ Ok(StatusCode::OK)
+}
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 0f0aed8..acb058c 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -1,5 +1,8 @@
//! REST API endpoints
+pub mod auth;
+pub mod users;
+
use axum::{
extract::{Path, State, Query},
Json,
diff --git a/server/src/api/users.rs b/server/src/api/users.rs
new file mode 100644
index 0000000..895717c
--- /dev/null
+++ b/server/src/api/users.rs
@@ -0,0 +1,592 @@
+//! User management API endpoints (admin only)
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::auth::{hash_password, AdminUser};
+use crate::db;
+use crate::AppState;
+
+use super::auth::ErrorResponse;
+
+/// User info response
+#[derive(Debug, Serialize)]
+pub struct UserInfo {
+ pub id: String,
+ pub username: String,
+ pub email: Option,
+ pub role: String,
+ pub enabled: bool,
+ pub created_at: String,
+ pub last_login: Option,
+ pub permissions: Vec,
+}
+
+/// Create user request
+#[derive(Debug, Deserialize)]
+pub struct CreateUserRequest {
+ pub username: String,
+ pub password: String,
+ pub email: Option,
+ pub role: String,
+ pub permissions: Option>,
+}
+
+/// Update user request
+#[derive(Debug, Deserialize)]
+pub struct UpdateUserRequest {
+ pub email: Option,
+ pub role: String,
+ pub enabled: bool,
+ pub password: Option,
+}
+
+/// Set permissions request
+#[derive(Debug, Deserialize)]
+pub struct SetPermissionsRequest {
+ pub permissions: Vec,
+}
+
+/// Set client access request
+#[derive(Debug, Deserialize)]
+pub struct SetClientAccessRequest {
+ pub client_ids: Vec,
+}
+
+/// GET /api/users - List all users
+pub async fn list_users(
+ State(state): State,
+ _admin: AdminUser,
+) -> Result>, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let users = db::get_all_users(db.pool())
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to fetch users".to_string(),
+ }),
+ )
+ })?;
+
+ let mut result = Vec::new();
+ for user in users {
+ let permissions = db::get_user_permissions(db.pool(), user.id)
+ .await
+ .unwrap_or_default();
+
+ result.push(UserInfo {
+ id: user.id.to_string(),
+ username: user.username,
+ email: user.email,
+ role: user.role,
+ enabled: user.enabled,
+ created_at: user.created_at.to_rfc3339(),
+ last_login: user.last_login.map(|t| t.to_rfc3339()),
+ permissions,
+ });
+ }
+
+ Ok(Json(result))
+}
+
+/// POST /api/users - Create new user
+pub async fn create_user(
+ State(state): State,
+ _admin: AdminUser,
+ Json(request): Json,
+) -> Result, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ // Validate role
+ let valid_roles = ["admin", "operator", "viewer"];
+ if !valid_roles.contains(&request.role.as_str()) {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: format!("Invalid role. Must be one of: {:?}", valid_roles),
+ }),
+ ));
+ }
+
+ // Validate password
+ if request.password.len() < 8 {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Password must be at least 8 characters".to_string(),
+ }),
+ ));
+ }
+
+ // Check if username exists
+ if db::get_user_by_username(db.pool(), &request.username)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Database error".to_string(),
+ }),
+ )
+ })?
+ .is_some()
+ {
+ return Err((
+ StatusCode::CONFLICT,
+ Json(ErrorResponse {
+ error: "Username already exists".to_string(),
+ }),
+ ));
+ }
+
+ // Hash password
+ let password_hash = hash_password(&request.password).map_err(|e| {
+ tracing::error!("Password hashing error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to hash password".to_string(),
+ }),
+ )
+ })?;
+
+ // Create user
+ let user = db::create_user(
+ db.pool(),
+ &request.username,
+ &password_hash,
+ request.email.as_deref(),
+ &request.role,
+ )
+ .await
+ .map_err(|e| {
+ tracing::error!("Failed to create user: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to create user".to_string(),
+ }),
+ )
+ })?;
+
+ // Set initial permissions if provided
+ let permissions = if let Some(perms) = request.permissions {
+ db::set_user_permissions(db.pool(), user.id, &perms)
+ .await
+ .map_err(|e| {
+ tracing::error!("Failed to set permissions: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to set permissions".to_string(),
+ }),
+ )
+ })?;
+ perms
+ } else {
+ // Default permissions based on role
+ let default_perms = match request.role.as_str() {
+ "admin" => vec!["view", "control", "transfer", "manage_users", "manage_clients"],
+ "operator" => vec!["view", "control", "transfer"],
+ "viewer" => vec!["view"],
+ _ => vec!["view"],
+ };
+ let perms: Vec = default_perms.into_iter().map(String::from).collect();
+ db::set_user_permissions(db.pool(), user.id, &perms)
+ .await
+ .ok();
+ perms
+ };
+
+ tracing::info!("Created user: {} ({})", user.username, user.role);
+
+ Ok(Json(UserInfo {
+ id: user.id.to_string(),
+ username: user.username,
+ email: user.email,
+ role: user.role,
+ enabled: user.enabled,
+ created_at: user.created_at.to_rfc3339(),
+ last_login: None,
+ permissions,
+ }))
+}
+
+/// GET /api/users/:id - Get user details
+pub async fn get_user(
+ State(state): State,
+ _admin: AdminUser,
+ Path(id): Path,
+) -> Result, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = Uuid::parse_str(&id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ let user = db::get_user_by_id(db.pool(), user_id)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Database error".to_string(),
+ }),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "User not found".to_string(),
+ }),
+ )
+ })?;
+
+ let permissions = db::get_user_permissions(db.pool(), user.id)
+ .await
+ .unwrap_or_default();
+
+ Ok(Json(UserInfo {
+ id: user.id.to_string(),
+ username: user.username,
+ email: user.email,
+ role: user.role,
+ enabled: user.enabled,
+ created_at: user.created_at.to_rfc3339(),
+ last_login: user.last_login.map(|t| t.to_rfc3339()),
+ permissions,
+ }))
+}
+
+/// PUT /api/users/:id - Update user
+pub async fn update_user(
+ State(state): State,
+ admin: AdminUser,
+ Path(id): Path,
+ Json(request): Json,
+) -> Result, (StatusCode, Json)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = Uuid::parse_str(&id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ // Prevent admin from disabling themselves
+ if user_id.to_string() == admin.0.user_id && !request.enabled {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Cannot disable your own account".to_string(),
+ }),
+ ));
+ }
+
+ // Validate role
+ let valid_roles = ["admin", "operator", "viewer"];
+ if !valid_roles.contains(&request.role.as_str()) {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: format!("Invalid role. Must be one of: {:?}", valid_roles),
+ }),
+ ));
+ }
+
+ // Update user
+ let user = db::update_user(
+ db.pool(),
+ user_id,
+ request.email.as_deref(),
+ &request.role,
+ request.enabled,
+ )
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to update user".to_string(),
+ }),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "User not found".to_string(),
+ }),
+ )
+ })?;
+
+ // Update password if provided
+ if let Some(password) = request.password {
+ if password.len() < 8 {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Password must be at least 8 characters".to_string(),
+ }),
+ ));
+ }
+
+ let password_hash = hash_password(&password).map_err(|e| {
+ tracing::error!("Password hashing error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to hash password".to_string(),
+ }),
+ )
+ })?;
+
+ db::update_user_password(db.pool(), user_id, &password_hash)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to update password".to_string(),
+ }),
+ )
+ })?;
+ }
+
+ let permissions = db::get_user_permissions(db.pool(), user.id)
+ .await
+ .unwrap_or_default();
+
+ tracing::info!("Updated user: {}", user.username);
+
+ Ok(Json(UserInfo {
+ id: user.id.to_string(),
+ username: user.username,
+ email: user.email,
+ role: user.role,
+ enabled: user.enabled,
+ created_at: user.created_at.to_rfc3339(),
+ last_login: user.last_login.map(|t| t.to_rfc3339()),
+ permissions,
+ }))
+}
+
+/// DELETE /api/users/:id - Delete user
+pub async fn delete_user(
+ State(state): State,
+ admin: AdminUser,
+ Path(id): Path,
+) -> Result)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = Uuid::parse_str(&id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ // Prevent admin from deleting themselves
+ if user_id.to_string() == admin.0.user_id {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Cannot delete your own account".to_string(),
+ }),
+ ));
+ }
+
+ let deleted = db::delete_user(db.pool(), user_id)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to delete user".to_string(),
+ }),
+ )
+ })?;
+
+ if deleted {
+ tracing::info!("Deleted user: {}", id);
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ Err((
+ StatusCode::NOT_FOUND,
+ Json(ErrorResponse {
+ error: "User not found".to_string(),
+ }),
+ ))
+ }
+}
+
+/// PUT /api/users/:id/permissions - Set user permissions
+pub async fn set_permissions(
+ State(state): State,
+ _admin: AdminUser,
+ Path(id): Path,
+ Json(request): Json,
+) -> Result)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = Uuid::parse_str(&id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ // Validate permissions
+ let valid_permissions = ["view", "control", "transfer", "manage_users", "manage_clients"];
+ for perm in &request.permissions {
+ if !valid_permissions.contains(&perm.as_str()) {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: format!("Invalid permission: {}. Valid: {:?}", perm, valid_permissions),
+ }),
+ ));
+ }
+ }
+
+ db::set_user_permissions(db.pool(), user_id, &request.permissions)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to set permissions".to_string(),
+ }),
+ )
+ })?;
+
+ tracing::info!("Updated permissions for user: {}", id);
+ Ok(StatusCode::OK)
+}
+
+/// PUT /api/users/:id/clients - Set user client access
+pub async fn set_client_access(
+ State(state): State,
+ _admin: AdminUser,
+ Path(id): Path,
+ Json(request): Json,
+) -> Result)> {
+ let db = state.db.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ErrorResponse {
+ error: "Database not available".to_string(),
+ }),
+ )
+ })?;
+
+ let user_id = Uuid::parse_str(&id).map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid user ID".to_string(),
+ }),
+ )
+ })?;
+
+ // Parse client IDs
+ let client_ids: Result, _> = request
+ .client_ids
+ .iter()
+ .map(|s| Uuid::parse_str(s))
+ .collect();
+
+ let client_ids = client_ids.map_err(|_| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ErrorResponse {
+ error: "Invalid client ID format".to_string(),
+ }),
+ )
+ })?;
+
+ db::set_user_client_access(db.pool(), user_id, &client_ids)
+ .await
+ .map_err(|e| {
+ tracing::error!("Database error: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ErrorResponse {
+ error: "Failed to set client access".to_string(),
+ }),
+ )
+ })?;
+
+ tracing::info!("Updated client access for user: {}", id);
+ Ok(StatusCode::OK)
+}
diff --git a/server/src/auth/jwt.rs b/server/src/auth/jwt.rs
new file mode 100644
index 0000000..f894ea3
--- /dev/null
+++ b/server/src/auth/jwt.rs
@@ -0,0 +1,140 @@
+//! JWT token handling
+
+use anyhow::{anyhow, Result};
+use chrono::{Duration, Utc};
+use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// JWT claims
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct Claims {
+ /// Subject (user ID)
+ pub sub: String,
+ /// Username
+ pub username: String,
+ /// Role (admin, operator, viewer)
+ pub role: String,
+ /// Permissions list
+ pub permissions: Vec,
+ /// Expiration time (unix timestamp)
+ pub exp: i64,
+ /// Issued at (unix timestamp)
+ pub iat: i64,
+}
+
+impl Claims {
+ /// Check if user has a specific permission
+ pub fn has_permission(&self, permission: &str) -> bool {
+ // Admins have all permissions
+ if self.role == "admin" {
+ return true;
+ }
+ self.permissions.contains(&permission.to_string())
+ }
+
+ /// Check if user is admin
+ pub fn is_admin(&self) -> bool {
+ self.role == "admin"
+ }
+
+ /// Get user ID as UUID
+ pub fn user_id(&self) -> Result {
+ Uuid::parse_str(&self.sub).map_err(|e| anyhow!("Invalid user ID in token: {}", e))
+ }
+}
+
+/// JWT configuration
+#[derive(Clone)]
+pub struct JwtConfig {
+ secret: String,
+ expiry_hours: i64,
+}
+
+impl JwtConfig {
+ /// Create new JWT config
+ pub fn new(secret: String, expiry_hours: i64) -> Self {
+ Self { secret, expiry_hours }
+ }
+
+ /// Create a JWT token for a user
+ pub fn create_token(
+ &self,
+ user_id: Uuid,
+ username: &str,
+ role: &str,
+ permissions: Vec,
+ ) -> Result {
+ let now = Utc::now();
+ let exp = now + Duration::hours(self.expiry_hours);
+
+ let claims = Claims {
+ sub: user_id.to_string(),
+ username: username.to_string(),
+ role: role.to_string(),
+ permissions,
+ exp: exp.timestamp(),
+ iat: now.timestamp(),
+ };
+
+ let token = encode(
+ &Header::default(),
+ &claims,
+ &EncodingKey::from_secret(self.secret.as_bytes()),
+ )
+ .map_err(|e| anyhow!("Failed to create token: {}", e))?;
+
+ Ok(token)
+ }
+
+ /// Validate and decode a JWT token
+ pub fn validate_token(&self, token: &str) -> Result {
+ let token_data = decode::(
+ token,
+ &DecodingKey::from_secret(self.secret.as_bytes()),
+ &Validation::default(),
+ )
+ .map_err(|e| anyhow!("Invalid token: {}", e))?;
+
+ Ok(token_data.claims)
+ }
+}
+
+/// Default JWT secret if not configured (NOT for production!)
+pub fn default_jwt_secret() -> String {
+ // In production, this should come from environment variable
+ std::env::var("JWT_SECRET").unwrap_or_else(|_| {
+ tracing::warn!("JWT_SECRET not set, using default (INSECURE!)");
+ "guruconnect-dev-secret-change-me-in-production".to_string()
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_create_and_validate_token() {
+ let config = JwtConfig::new("test-secret".to_string(), 24);
+ let user_id = Uuid::new_v4();
+
+ let token = config.create_token(
+ user_id,
+ "testuser",
+ "admin",
+ vec!["view".to_string(), "control".to_string()],
+ ).unwrap();
+
+ let claims = config.validate_token(&token).unwrap();
+ assert_eq!(claims.username, "testuser");
+ assert_eq!(claims.role, "admin");
+ assert!(claims.has_permission("view"));
+ assert!(claims.has_permission("manage_users")); // admin has all
+ }
+
+ #[test]
+ fn test_invalid_token() {
+ let config = JwtConfig::new("test-secret".to_string(), 24);
+ assert!(config.validate_token("invalid.token.here").is_err());
+ }
+}
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 80117a9..79ab691 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -3,17 +3,51 @@
//! Handles JWT validation for dashboard users and API key
//! validation for agents.
+pub mod jwt;
+pub mod password;
+
+pub use jwt::{Claims, JwtConfig};
+pub use password::{hash_password, verify_password, generate_random_password};
+
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
+use std::sync::Arc;
/// Authenticated user from JWT
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: String,
- pub email: String,
- pub roles: Vec,
+ pub username: String,
+ pub role: String,
+ pub permissions: Vec,
+}
+
+impl AuthenticatedUser {
+ /// Check if user has a specific permission
+ pub fn has_permission(&self, permission: &str) -> bool {
+ if self.role == "admin" {
+ return true;
+ }
+ self.permissions.contains(&permission.to_string())
+ }
+
+ /// Check if user is admin
+ pub fn is_admin(&self) -> bool {
+ self.role == "admin"
+ }
+}
+
+impl From for AuthenticatedUser {
+ fn from(claims: Claims) -> Self {
+ Self {
+ user_id: claims.sub,
+ username: claims.username,
+ role: claims.role,
+ permissions: claims.permissions,
+ }
+ }
}
/// Authenticated agent from API key
@@ -23,7 +57,21 @@ pub struct AuthenticatedAgent {
pub org_id: String,
}
-/// Extract authenticated user from request (placeholder for MVP)
+/// JWT configuration stored in app state
+#[derive(Clone)]
+pub struct AuthState {
+ pub jwt_config: Arc,
+}
+
+impl AuthState {
+ pub fn new(jwt_secret: String, expiry_hours: i64) -> Self {
+ Self {
+ jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)),
+ }
+ }
+}
+
+/// Extract authenticated user from request
#[axum::async_trait]
impl FromRequestParts for AuthenticatedUser
where
@@ -32,28 +80,77 @@ where
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result {
- // TODO: Implement JWT validation
- // For MVP, accept any request
-
- // Look for Authorization header
- let _auth_header = parts
+ // Get Authorization header
+ let auth_header = parts
.headers
.get("Authorization")
- .and_then(|v| v.to_str().ok());
+ .and_then(|v| v.to_str().ok())
+ .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
- // Placeholder - in production, validate JWT
- Ok(AuthenticatedUser {
- user_id: "mvp-user".to_string(),
- email: "mvp@example.com".to_string(),
- roles: vec!["admin".to_string()],
- })
+ // Extract Bearer token
+ let token = auth_header
+ .strip_prefix("Bearer ")
+ .ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
+
+ // Get JWT config from extensions (set by middleware)
+ let jwt_config = parts
+ .extensions
+ .get::>()
+ .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
+
+ // Validate token
+ let claims = jwt_config
+ .validate_token(token)
+ .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?;
+
+ Ok(AuthenticatedUser::from(claims))
+ }
+}
+
+/// Optional authenticated user (doesn't reject if not authenticated)
+#[derive(Debug, Clone)]
+pub struct OptionalUser(pub Option);
+
+#[axum::async_trait]
+impl FromRequestParts for OptionalUser
+where
+ S: Send + Sync,
+{
+ type Rejection = (StatusCode, &'static str);
+
+ async fn from_request_parts(parts: &mut Parts, state: &S) -> Result {
+ match AuthenticatedUser::from_request_parts(parts, state).await {
+ Ok(user) => Ok(OptionalUser(Some(user))),
+ Err(_) => Ok(OptionalUser(None)),
+ }
+ }
+}
+
+/// Require admin role
+#[derive(Debug, Clone)]
+pub struct AdminUser(pub AuthenticatedUser);
+
+#[axum::async_trait]
+impl FromRequestParts for AdminUser
+where
+ S: Send + Sync,
+{
+ type Rejection = (StatusCode, &'static str);
+
+ async fn from_request_parts(parts: &mut Parts, state: &S) -> Result {
+ let user = AuthenticatedUser::from_request_parts(parts, state).await?;
+ if user.is_admin() {
+ Ok(AdminUser(user))
+ } else {
+ Err((StatusCode::FORBIDDEN, "Admin access required"))
+ }
}
}
/// Validate an agent API key (placeholder for MVP)
pub fn validate_agent_key(_api_key: &str) -> Option {
- // TODO: Implement actual API key validation
- // For MVP, accept any key
+ // TODO: Implement actual API key validation against database
+ // For now, accept any key for agent connections
Some(AuthenticatedAgent {
agent_id: "mvp-agent".to_string(),
org_id: "mvp-org".to_string(),
diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs
new file mode 100644
index 0000000..1020465
--- /dev/null
+++ b/server/src/auth/password.rs
@@ -0,0 +1,57 @@
+//! Password hashing using Argon2id
+
+use anyhow::{anyhow, Result};
+use argon2::{
+ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
+ Argon2,
+};
+
+/// Hash a password using Argon2id
+pub fn hash_password(password: &str) -> Result {
+ let salt = SaltString::generate(&mut OsRng);
+ let argon2 = Argon2::default();
+ let hash = argon2
+ .hash_password(password.as_bytes(), &salt)
+ .map_err(|e| anyhow!("Failed to hash password: {}", e))?;
+ Ok(hash.to_string())
+}
+
+/// Verify a password against a stored hash
+pub fn verify_password(password: &str, hash: &str) -> Result {
+ let parsed_hash = PasswordHash::new(hash)
+ .map_err(|e| anyhow!("Invalid password hash format: {}", e))?;
+ let argon2 = Argon2::default();
+ Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
+}
+
+/// Generate a random password (for initial admin)
+pub fn generate_random_password(length: usize) -> String {
+ use rand::Rng;
+ const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
+ let mut rng = rand::thread_rng();
+ (0..length)
+ .map(|_| {
+ let idx = rng.gen_range(0..CHARSET.len());
+ CHARSET[idx] as char
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_hash_and_verify() {
+ let password = "test_password_123";
+ let hash = hash_password(password).unwrap();
+ assert!(verify_password(password, &hash).unwrap());
+ assert!(!verify_password("wrong_password", &hash).unwrap());
+ }
+
+ #[test]
+ fn test_random_password() {
+ let password = generate_random_password(16);
+ assert_eq!(password.len(), 16);
+ }
+}
diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs
index d7be2aa..a12579c 100644
--- a/server/src/db/mod.rs
+++ b/server/src/db/mod.rs
@@ -7,6 +7,7 @@ pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
+pub mod users;
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
@@ -17,6 +18,7 @@ pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
+pub use users::*;
/// Database connection pool wrapper
#[derive(Clone)]
diff --git a/server/src/db/users.rs b/server/src/db/users.rs
new file mode 100644
index 0000000..f75d219
--- /dev/null
+++ b/server/src/db/users.rs
@@ -0,0 +1,283 @@
+//! User database operations
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use sqlx::PgPool;
+use uuid::Uuid;
+
+/// User record from database
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct User {
+ pub id: Uuid,
+ pub username: String,
+ pub password_hash: String,
+ pub email: Option,
+ pub role: String,
+ pub enabled: bool,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+ pub last_login: Option>,
+}
+
+/// User without password hash (for API responses)
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct UserInfo {
+ pub id: Uuid,
+ pub username: String,
+ pub email: Option,
+ pub role: String,
+ pub enabled: bool,
+ pub created_at: DateTime,
+ pub last_login: Option>,
+ pub permissions: Vec,
+}
+
+impl From for UserInfo {
+ fn from(u: User) -> Self {
+ Self {
+ id: u.id,
+ username: u.username,
+ email: u.email,
+ role: u.role,
+ enabled: u.enabled,
+ created_at: u.created_at,
+ last_login: u.last_login,
+ permissions: Vec::new(), // Filled in by caller
+ }
+ }
+}
+
+/// Get user by username
+pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result
+
+
+
@@ -546,19 +562,59 @@
});
// Check auth
- const token = localStorage.getItem("token");
- const user = JSON.parse(localStorage.getItem("user") || "null");
+ const token = localStorage.getItem("guruconnect_token");
+ const user = JSON.parse(localStorage.getItem("guruconnect_user") || "null");
- if (!token) {
- document.getElementById("userInfo").textContent = "Demo Mode";
- } else if (user) {
- document.getElementById("userInfo").textContent = user.email || user.name || "Technician";
+ // Verify authentication
+ async function checkAuth() {
+ if (!token) {
+ window.location.href = "/login";
+ return;
+ }
+
+ try {
+ const response = await fetch("/api/auth/me", {
+ headers: { "Authorization": `Bearer ${token}` }
+ });
+
+ if (!response.ok) {
+ localStorage.removeItem("guruconnect_token");
+ localStorage.removeItem("guruconnect_user");
+ window.location.href = "/login";
+ return;
+ }
+
+ const userData = await response.json();
+
+ // Update user display
+ document.getElementById("userInfo").textContent = userData.username + " (" + userData.role + ")";
+
+ // Show admin-only elements
+ if (userData.role === "admin") {
+ document.querySelectorAll(".admin-only").forEach(el => {
+ el.style.display = "";
+ });
+ }
+ } catch (err) {
+ console.error("Auth check failed:", err);
+ // Don't redirect on network error, just show what we have
+ if (user) {
+ document.getElementById("userInfo").textContent = user.username || "User";
+ if (user.role === "admin") {
+ document.querySelectorAll(".admin-only").forEach(el => {
+ el.style.display = "";
+ });
+ }
+ }
+ }
}
+ checkAuth();
+
// Logout
document.getElementById("logoutBtn").addEventListener("click", () => {
- localStorage.removeItem("token");
- localStorage.removeItem("user");
+ localStorage.removeItem("guruconnect_token");
+ localStorage.removeItem("guruconnect_user");
window.location.href = "/login";
});
diff --git a/server/static/login.html b/server/static/login.html
index 23c5819..34ad38c 100644
--- a/server/static/login.html
+++ b/server/static/login.html
@@ -3,7 +3,7 @@
- GuruConnect - Technician Login
+ GuruConnect - Login
GuruConnect
-
Technician Login
+
Sign in to your account
-
-
@@ -177,10 +157,29 @@
const loginBtn = document.getElementById("loginBtn");
const errorMessage = document.getElementById("errorMessage");
+ // Check if already logged in
+ const token = localStorage.getItem("guruconnect_token");
+ if (token) {
+ // Verify token is still valid
+ fetch('/api/auth/me', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ }).then(res => {
+ if (res.ok) {
+ window.location.href = '/dashboard';
+ } else {
+ localStorage.removeItem('guruconnect_token');
+ localStorage.removeItem('guruconnect_user');
+ }
+ }).catch(() => {
+ localStorage.removeItem('guruconnect_token');
+ localStorage.removeItem('guruconnect_user');
+ });
+ }
+
form.addEventListener("submit", async (e) => {
e.preventDefault();
- const email = document.getElementById("email").value;
+ const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
setLoading(true);
@@ -190,7 +189,7 @@
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password })
+ body: JSON.stringify({ username, password })
});
const data = await response.json();
@@ -201,12 +200,13 @@
return;
}
- localStorage.setItem("token", data.token);
- localStorage.setItem("user", JSON.stringify(data.user));
+ // Store token and user info
+ localStorage.setItem("guruconnect_token", data.token);
+ localStorage.setItem("guruconnect_user", JSON.stringify(data.user));
window.location.href = "/dashboard";
} catch (err) {
- showError("Auth not configured yet. Use the demo link below.");
+ showError("Connection error. Please try again.");
setLoading(false);
}
});
@@ -222,9 +222,8 @@
loginBtn.querySelector(".btn-text").textContent = loading ? "Signing in..." : "Sign In";
}
- if (localStorage.getItem("token")) {
- window.location.href = "/dashboard";
- }
+ // Focus username field
+ document.getElementById("username").focus();