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

@ -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<AppState> {
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<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" })))
}