From 0a70afbbe9de7e8c1e404192f1ba55ff404419ff Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 7 May 2026 16:21:53 +0000 Subject: [PATCH] feat: Phase 1 - user/password API extensions and auth route fix --- crates/pm-auth/src/session.rs | 13 +- crates/pm-core/src/lib.rs | 3 +- crates/pm-core/src/models.rs | 16 +++ crates/pm-web/src/main.rs | 3 +- crates/pm-web/src/routes/auth.rs | 64 +++++++++ crates/pm-web/src/routes/users.rs | 215 +++++++++++++++++++++++++++++- frontend/src/api/client.ts | 20 +++ frontend/src/types/index.ts | 27 ++++ 8 files changed, 352 insertions(+), 9 deletions(-) diff --git a/crates/pm-auth/src/session.rs b/crates/pm-auth/src/session.rs index 35c5b20..eb5404c 100644 --- a/crates/pm-auth/src/session.rs +++ b/crates/pm-auth/src/session.rs @@ -24,6 +24,8 @@ pub enum SessionError { InvalidCredentials, #[error("Account is disabled")] AccountDisabled, + #[error("Password reset required")] + PasswordResetRequired, #[error("MFA required")] MfaRequired, #[error("Invalid MFA code")] @@ -75,6 +77,7 @@ struct DbUser { totp_secret: Option, mfa_enabled: bool, is_active: bool, + force_password_reset: bool, } /// Login request payload. @@ -107,7 +110,7 @@ pub async fn login( let user: Option = sqlx::query_as( r#" SELECT id, username, display_name, role, auth_provider, - password_hash, totp_secret, mfa_enabled, is_active + password_hash, totp_secret, mfa_enabled, is_active, force_password_reset FROM users WHERE username = $1 AND auth_provider = 'local' "#, @@ -141,6 +144,12 @@ pub async fn login( return Err(SessionError::AccountDisabled); } + // 3b. Check if password reset is required + if user.force_password_reset { + tracing::warn!(username = %req.username, "Login blocked: password reset required"); + return Err(SessionError::PasswordResetRequired); + } + // 4. MFA check if user.mfa_enabled { let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?; @@ -207,7 +216,7 @@ pub async fn refresh_session( let user: DbUser = sqlx::query_as( r#" SELECT id, username, display_name, role, auth_provider, - password_hash, totp_secret, mfa_enabled, is_active + password_hash, totp_secret, mfa_enabled, is_active, force_password_reset FROM users WHERE id = $1 "#, ) diff --git a/crates/pm-core/src/lib.rs b/crates/pm-core/src/lib.rs index 2f84fc2..9530735 100644 --- a/crates/pm-core/src/lib.rs +++ b/crates/pm-core/src/lib.rs @@ -13,7 +13,8 @@ pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH}; pub use error::{AppError, ErrorResponse}; pub use models::{ AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest, - CreateUserRequest, DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck, + ChangePasswordRequest, AdminResetPasswordRequest, CreateUserRequest, + DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck, HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary, RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest, User, UserRole as DbUserRole, diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index 876817f..5c9590c 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -251,6 +251,22 @@ pub struct UpdateUserRequest { pub email: Option, pub role: Option, pub is_active: Option, + pub force_password_reset: Option, +} + +/// Self-service password change payload +#[derive(Debug, Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +/// Admin password reset payload +#[derive(Debug, Deserialize)] +pub struct AdminResetPasswordRequest { + pub new_password: String, + #[serde(default)] + pub force_password_reset: bool, } // ============================================================ diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index b6e49fb..9ea1d64 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -161,7 +161,8 @@ pub fn build_router(state: AppState) -> Router { // All protected API routes — require valid JWT let protected_api = Router::new() // Auth: MFA setup/verify - .merge(routes::auth::protected_router()) + // Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*) + .nest("/auth", routes::auth::protected_router()) // Hosts .nest("/hosts", routes::hosts::router()) // Host-scoped certificate endpoints (merged separately to avoid conflict) diff --git a/crates/pm-web/src/routes/auth.rs b/crates/pm-web/src/routes/auth.rs index 17fb69a..10ecfaa 100644 --- a/crates/pm-web/src/routes/auth.rs +++ b/crates/pm-web/src/routes/auth.rs @@ -14,12 +14,14 @@ use axum::{ http::{HeaderMap, StatusCode}, response::Json, routing::{get, post}, + routing::delete, Router, }; use pm_auth::{ mfa_totp, rbac::AuthUser, session::{self, LoginRequest, LoginResponse}, + verify_password, }; use serde::Deserialize; use serde_json::{json, Value}; @@ -45,6 +47,7 @@ pub fn protected_router() -> Router { Router::new() .route("/mfa/setup", get(mfa_setup_handler)) .route("/mfa/verify", post(mfa_verify_handler)) + .route("/mfa", delete(disable_mfa)) } // ============================================================ @@ -105,6 +108,11 @@ async fn login_handler( "account_disabled", "Account is disabled", ), + SessionError::PasswordResetRequired => ( + StatusCode::FORBIDDEN, + "password_reset_required", + "Password reset is required before login", + ), _ => { tracing::error!(error = %e, "Login error"); ( @@ -266,3 +274,59 @@ async fn mfa_verify_handler( tracing::info!(user_id = %auth_user.user_id, "MFA enabled for user"); Ok(Json(json!({ "message": "MFA enabled successfully" }))) } + +// ============================================================ +// DELETE /api/v1/auth/mfa (JWT required — disable own MFA) +// ============================================================ + +#[derive(Debug, Deserialize)] +struct DisableMfaRequest { + password: String, +} + +async fn disable_mfa( + State(state): State, + auth_user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Verify current password to confirm identity + let hash: Option = sqlx::query_scalar( + "SELECT password_hash FROM users WHERE id = $1", + ) + .bind(auth_user.user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to fetch password hash"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })? + .flatten(); + + let hash_str = hash.unwrap_or_default(); + let valid = verify_password(&req.password, &hash_str).unwrap_or(false); + + if !valid { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })), + )); + } + + sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE WHERE id = $1") + .bind(auth_user.user_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to disable MFA"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Failed to disable MFA" } })), + ) + })?; + + tracing::info!(user_id = %auth_user.user_id, "MFA disabled for user"); + Ok(Json(json!({ "message": "MFA disabled successfully" }))) +} diff --git a/crates/pm-web/src/routes/users.rs b/crates/pm-web/src/routes/users.rs index 3674ca5..68e9cac 100644 --- a/crates/pm-web/src/routes/users.rs +++ b/crates/pm-web/src/routes/users.rs @@ -15,10 +15,10 @@ use axum::{ routing::{delete, get, post, put}, Router, }; -use pm_auth::{hash_password, rbac::AuthUser, session::force_logout}; +use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password}; use pm_core::{ audit::{log_event, AuditAction}, - models::{CreateUserRequest, UpdateUserRequest, User}, + models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User}, }; use serde_json::{json, Value}; use uuid::Uuid; @@ -29,7 +29,10 @@ pub fn router() -> Router { Router::new() .route("/", get(list_users).post(create_user)) .route("/me", get(get_current_user)) + .route("/me/password", put(change_own_password)) .route("/{id}", get(get_user).put(update_user).delete(delete_user)) + .route("/{id}/password", put(admin_reset_password)) + .route("/{id}/mfa", delete(admin_disable_mfa)) .route("/{id}/revoke", post(revoke_user_sessions)) } @@ -191,11 +194,11 @@ async fn update_user( )); } // Only admins can change role or active status - if (req.role.is_some() || req.is_active.is_some()) && !auth.role.is_admin() { + if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some()) && !auth.role.is_admin() { return Err(( StatusCode::FORBIDDEN, Json( - json!({ "error": { "code": "forbidden", "message": "Admin role required to change role or status" } }), + json!({ "error": { "code": "forbidden", "message": "Admin role required to change role, status, or force_password_reset" } }), ), )); } @@ -211,13 +214,15 @@ async fn update_user( email = COALESCE($2, email), role = COALESCE($3::user_role, role), is_active = COALESCE($4, is_active), + force_password_reset = COALESCE($5, force_password_reset), updated_at = NOW() - WHERE id = $5"#, + WHERE id = $6"#, ) .bind(req.display_name.as_deref()) .bind(req.email.as_deref()) .bind(role_str) .bind(req.is_active) + .bind(req.force_password_reset) .bind(id) .execute(&state.db) .await @@ -330,3 +335,203 @@ async fn revoke_user_sessions( json!({ "message": "Sessions revoked", "count": count }), )) } + +// ============================================================ +// PUT /api/v1/users/me/password — change own password +// ============================================================ + +async fn change_own_password( + State(state): State, + auth: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Fetch current password hash + let hash: Option = sqlx::query_scalar( + "SELECT password_hash FROM users WHERE id = $1", + ) + .bind(auth.user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to fetch password hash"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })? + .flatten(); + + let hash_str = hash.unwrap_or_default(); + let valid = verify_password(&req.current_password, &hash_str).unwrap_or(false); + + if !valid { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })), + )); + } + + let new_hash = hash_password(&req.new_password).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), + ) + })?; + + sqlx::query( + "UPDATE users SET password_hash = $1, force_password_reset = FALSE, updated_at = NOW() WHERE id = $2", + ) + .bind(&new_hash) + .bind(auth.user_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to update password"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Failed to update password" } })), + ) + })?; + + log_event( + &state.db, + AuditAction::UserUpdated, + Some(auth.user_id), + Some(&auth.username), + Some("user"), + Some(&auth.user_id.to_string()), + json!({ "action": "password_change" }), + None, + None, + ) + .await; + + Ok(Json(json!({ "message": "Password changed successfully" }))) +} + +// ============================================================ +// PUT /api/v1/users/:id/password — admin reset password +// ============================================================ + +async fn admin_reset_password( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if !auth.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + )); + } + + // Verify target user exists + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") + .bind(id) + .fetch_one(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), + ) + })?; + + if !exists { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "User not found" } })), + )); + } + + let new_hash = hash_password(&req.new_password).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), + ) + })?; + + sqlx::query( + "UPDATE users SET password_hash = $1, force_password_reset = $2, updated_at = NOW() WHERE id = $3", + ) + .bind(&new_hash) + .bind(req.force_password_reset) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to reset password"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Failed to reset password" } })), + ) + })?; + + log_event( + &state.db, + AuditAction::UserUpdated, + Some(auth.user_id), + Some(&auth.username), + Some("user"), + Some(&id.to_string()), + json!({ "action": "admin_password_reset", "force_password_reset": req.force_password_reset }), + None, + None, + ) + .await; + + Ok(Json(json!({ "message": "Password reset successfully" }))) +} + +// ============================================================ +// DELETE /api/v1/users/:id/mfa — admin disable MFA +// ============================================================ + +async fn admin_disable_mfa( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + if !auth.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + )); + } + + let rows = sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1") + .bind(id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to disable MFA"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Failed to disable MFA" } })), + ) + })? + .rows_affected(); + + if rows == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "User not found" } })), + )); + } + + log_event( + &state.db, + AuditAction::UserUpdated, + Some(auth.user_id), + Some(&auth.username), + Some("user"), + Some(&id.to_string()), + json!({ "action": "admin_mfa_disabled" }), + None, + None, + ) + .await; + + Ok(Json(json!({ "message": "MFA disabled successfully" }))) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b7aa096..5c04505 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,11 @@ import type { CreateHealthCheckRequest, UpdateHealthCheckRequest, HealthCheckListResponse, + User, + ChangePasswordRequest, + AdminResetPasswordRequest, + UpdateUserRequest, + CreateUserRequest, } from '../types' const BASE_URL = '/api/v1' @@ -291,3 +296,18 @@ export const healthChecksApi = { test: (hostId: string, checkId: string) => apiClient.post(`/hosts/${hostId}/health-checks/${checkId}/test`), } + +// ── Users API ────────────────────────────────────────────────────────────── +export const usersApi = { + list: () => apiClient.get('/users'), + get: (id: string) => apiClient.get(`/users/${id}`), + getMe: () => apiClient.get('/users/me'), + create: (data: CreateUserRequest) => apiClient.post('/users', data), + update: (id: string, data: UpdateUserRequest) => apiClient.put(`/users/${id}`, data), + delete: (id: string) => apiClient.delete(`/users/${id}`), + revokeSessions: (id: string) => apiClient.post(`/users/${id}/revoke`), + changePassword: (data: ChangePasswordRequest) => apiClient.put('/users/me/password', data), + adminResetPassword: (id: string, data: AdminResetPasswordRequest) => apiClient.put(`/users/${id}/password`, data), + adminDisableMfa: (id: string) => apiClient.delete(`/users/${id}/mfa`), + disableMfa: (password: string) => apiClient.delete('/auth/mfa', { data: { password } }), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bd3c31a..1ddab44 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -53,9 +53,36 @@ export interface User { auth_provider: AuthProvider mfa_enabled: boolean is_active: boolean + force_password_reset: boolean last_login_at?: string } +export interface ChangePasswordRequest { + current_password: string + new_password: string +} + +export interface AdminResetPasswordRequest { + new_password: string + force_password_reset?: boolean +} + +export interface UpdateUserRequest { + display_name?: string + email?: string + role?: string + is_active?: boolean + force_password_reset?: boolean +} + +export interface CreateUserRequest { + username: string + display_name?: string + email: string + role: string + password: string +} + export interface FleetStatus { total_hosts: number healthy: number