Private
Public Access
1
0

feat: Phase 1 - user/password API extensions and auth route fix
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 5s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped

This commit is contained in:
2026-05-07 16:21:53 +00:00
parent 42392ed9c7
commit 0a70afbbe9
8 changed files with 352 additions and 9 deletions

View File

@ -24,6 +24,8 @@ pub enum SessionError {
InvalidCredentials, InvalidCredentials,
#[error("Account is disabled")] #[error("Account is disabled")]
AccountDisabled, AccountDisabled,
#[error("Password reset required")]
PasswordResetRequired,
#[error("MFA required")] #[error("MFA required")]
MfaRequired, MfaRequired,
#[error("Invalid MFA code")] #[error("Invalid MFA code")]
@ -75,6 +77,7 @@ struct DbUser {
totp_secret: Option<String>, totp_secret: Option<String>,
mfa_enabled: bool, mfa_enabled: bool,
is_active: bool, is_active: bool,
force_password_reset: bool,
} }
/// Login request payload. /// Login request payload.
@ -107,7 +110,7 @@ pub async fn login(
let user: Option<DbUser> = sqlx::query_as( let user: Option<DbUser> = sqlx::query_as(
r#" r#"
SELECT id, username, display_name, role, auth_provider, 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 FROM users
WHERE username = $1 AND auth_provider = 'local' WHERE username = $1 AND auth_provider = 'local'
"#, "#,
@ -141,6 +144,12 @@ pub async fn login(
return Err(SessionError::AccountDisabled); 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 // 4. MFA check
if user.mfa_enabled { if user.mfa_enabled {
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?; 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( let user: DbUser = sqlx::query_as(
r#" r#"
SELECT id, username, display_name, role, auth_provider, 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 FROM users WHERE id = $1
"#, "#,
) )

View File

@ -13,7 +13,8 @@ pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
pub use error::{AppError, ErrorResponse}; pub use error::{AppError, ErrorResponse};
pub use models::{ pub use models::{
AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest, AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest,
CreateUserRequest, DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck, ChangePasswordRequest, AdminResetPasswordRequest, CreateUserRequest,
DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck,
HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary, HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary,
RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest, RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest,
User, UserRole as DbUserRole, User, UserRole as DbUserRole,

View File

@ -251,6 +251,22 @@ pub struct UpdateUserRequest {
pub email: Option<String>, pub email: Option<String>,
pub role: Option<String>, pub role: Option<String>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub force_password_reset: Option<bool>,
}
/// 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,
} }
// ============================================================ // ============================================================

View File

@ -161,7 +161,8 @@ pub fn build_router(state: AppState) -> Router {
// All protected API routes — require valid JWT // All protected API routes — require valid JWT
let protected_api = Router::new() let protected_api = Router::new()
// Auth: MFA setup/verify // 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 // Hosts
.nest("/hosts", routes::hosts::router()) .nest("/hosts", routes::hosts::router())
// Host-scoped certificate endpoints (merged separately to avoid conflict) // Host-scoped certificate endpoints (merged separately to avoid conflict)

View File

@ -14,12 +14,14 @@ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Json, response::Json,
routing::{get, post}, routing::{get, post},
routing::delete,
Router, Router,
}; };
use pm_auth::{ use pm_auth::{
mfa_totp, mfa_totp,
rbac::AuthUser, rbac::AuthUser,
session::{self, LoginRequest, LoginResponse}, session::{self, LoginRequest, LoginResponse},
verify_password,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -45,6 +47,7 @@ pub fn protected_router() -> Router<AppState> {
Router::new() Router::new()
.route("/mfa/setup", get(mfa_setup_handler)) .route("/mfa/setup", get(mfa_setup_handler))
.route("/mfa/verify", post(mfa_verify_handler)) .route("/mfa/verify", post(mfa_verify_handler))
.route("/mfa", delete(disable_mfa))
} }
// ============================================================ // ============================================================
@ -105,6 +108,11 @@ async fn login_handler(
"account_disabled", "account_disabled",
"Account is disabled", "Account is disabled",
), ),
SessionError::PasswordResetRequired => (
StatusCode::FORBIDDEN,
"password_reset_required",
"Password reset is required before login",
),
_ => { _ => {
tracing::error!(error = %e, "Login error"); 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"); tracing::info!(user_id = %auth_user.user_id, "MFA enabled for user");
Ok(Json(json!({ "message": "MFA enabled successfully" }))) 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<AppState>,
auth_user: AuthUser,
Json(req): Json<DisableMfaRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Verify current password to confirm identity
let hash: Option<String> = 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" })))
}

View File

@ -15,10 +15,10 @@ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
Router, 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::{ use pm_core::{
audit::{log_event, AuditAction}, audit::{log_event, AuditAction},
models::{CreateUserRequest, UpdateUserRequest, User}, models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User},
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
@ -29,7 +29,10 @@ pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_users).post(create_user)) .route("/", get(list_users).post(create_user))
.route("/me", get(get_current_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}", 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)) .route("/{id}/revoke", post(revoke_user_sessions))
} }
@ -191,11 +194,11 @@ async fn update_user(
)); ));
} }
// Only admins can change role or active status // 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(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json( 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), email = COALESCE($2, email),
role = COALESCE($3::user_role, role), role = COALESCE($3::user_role, role),
is_active = COALESCE($4, is_active), is_active = COALESCE($4, is_active),
force_password_reset = COALESCE($5, force_password_reset),
updated_at = NOW() updated_at = NOW()
WHERE id = $5"#, WHERE id = $6"#,
) )
.bind(req.display_name.as_deref()) .bind(req.display_name.as_deref())
.bind(req.email.as_deref()) .bind(req.email.as_deref())
.bind(role_str) .bind(role_str)
.bind(req.is_active) .bind(req.is_active)
.bind(req.force_password_reset)
.bind(id) .bind(id)
.execute(&state.db) .execute(&state.db)
.await .await
@ -330,3 +335,203 @@ async fn revoke_user_sessions(
json!({ "message": "Sessions revoked", "count": count }), json!({ "message": "Sessions revoked", "count": count }),
)) ))
} }
// ============================================================
// PUT /api/v1/users/me/password — change own password
// ============================================================
async fn change_own_password(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<ChangePasswordRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Fetch current password hash
let hash: Option<String> = 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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<AdminResetPasswordRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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" })))
}

View File

@ -12,6 +12,11 @@ import type {
CreateHealthCheckRequest, CreateHealthCheckRequest,
UpdateHealthCheckRequest, UpdateHealthCheckRequest,
HealthCheckListResponse, HealthCheckListResponse,
User,
ChangePasswordRequest,
AdminResetPasswordRequest,
UpdateUserRequest,
CreateUserRequest,
} from '../types' } from '../types'
const BASE_URL = '/api/v1' const BASE_URL = '/api/v1'
@ -291,3 +296,18 @@ export const healthChecksApi = {
test: (hostId: string, checkId: string) => test: (hostId: string, checkId: string) =>
apiClient.post<HealthCheckWithResult>(`/hosts/${hostId}/health-checks/${checkId}/test`), apiClient.post<HealthCheckWithResult>(`/hosts/${hostId}/health-checks/${checkId}/test`),
} }
// ── Users API ──────────────────────────────────────────────────────────────
export const usersApi = {
list: () => apiClient.get<User[]>('/users'),
get: (id: string) => apiClient.get<User>(`/users/${id}`),
getMe: () => apiClient.get<User>('/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 } }),
}

View File

@ -53,9 +53,36 @@ export interface User {
auth_provider: AuthProvider auth_provider: AuthProvider
mfa_enabled: boolean mfa_enabled: boolean
is_active: boolean is_active: boolean
force_password_reset: boolean
last_login_at?: string 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 { export interface FleetStatus {
total_hosts: number total_hosts: number
healthy: number healthy: number