Private
Public Access
1
0

feat: Phase 4 - password validation, force password reset flow, account lockout, QR code for MFA
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 6s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
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 17:53:16 +00:00
parent b5b975e7e5
commit cc1214a963
13 changed files with 889 additions and 68 deletions

View File

@ -21,11 +21,13 @@ use pm_auth::{
mfa_totp,
rbac::AuthUser,
session::{self, LoginRequest, LoginResponse},
verify_password,
verify_password, hash_password, validate_password_strength,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::AppState;
// ============================================================
@ -37,6 +39,7 @@ pub fn public_router() -> Router<AppState> {
.route("/login", post(login_handler))
.route("/refresh", post(refresh_handler))
.route("/logout", post(logout_handler))
.route("/force-change-password", post(force_change_password_handler))
}
// ============================================================
@ -113,6 +116,11 @@ async fn login_handler(
"password_reset_required",
"Password reset is required before login",
),
SessionError::AccountLocked => (
StatusCode::LOCKED,
"account_locked",
"Account is locked due to too many failed login attempts",
),
_ => {
tracing::error!(error = %e, "Login error");
(
@ -214,6 +222,93 @@ async fn logout_handler(
// GET /api/v1/auth/mfa/setup (JWT required — via middleware)
// ============================================================
// ============================================================
// POST /api/v1/auth/force-change-password (PUBLIC — no JWT)
// ============================================================
#[derive(Debug, Deserialize)]
struct ForceChangePasswordRequest {
username: String,
current_password: String,
new_password: String,
}
async fn force_change_password_handler(
State(state): State<AppState>,
Json(req): Json<ForceChangePasswordRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
// Look up user by username
let row: Option<(Uuid, Option<String>, bool)> = sqlx::query_as(
"SELECT id, password_hash, force_password_reset FROM users WHERE username = $1 AND auth_provider = 'local'",
)
.bind(&req.username)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch user");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (user_id, hash_opt, _force_reset) = match row {
Some(r) => r,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
));
}
};
// Verify current password
let hash_str = hash_opt.as_deref().unwrap_or("");
let valid = verify_password(&req.current_password, hash_str).unwrap_or(false);
if !valid {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
));
}
// Hash and update password, clear force_password_reset
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, failed_login_attempts = 0, locked_until = NULL, updated_at = NOW() WHERE id = $2",
)
.bind(&new_hash)
.bind(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" } })),
)
})?;
tracing::info!(user_id = %user_id, username = %req.username, "Password changed via force-change-password");
Ok(Json(json!({ "message": "Password changed successfully" })))
}
async fn mfa_setup_handler(
auth_user: AuthUser,
) -> Result<Json<mfa_totp::TotpSetup>, (StatusCode, Json<Value>)> {

View File

@ -16,6 +16,7 @@ use axum::{
Router,
};
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
use pm_auth::validate_password_strength;
use pm_core::{
audit::{log_event, AuditAction},
models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User},
@ -77,6 +78,14 @@ async fn create_user(
));
}
// Validate password strength
if let Err(msg) = validate_password_strength(&req.password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let hash = hash_password(&req.password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@ -371,6 +380,14 @@ async fn change_own_password(
));
}
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let new_hash = hash_password(&req.new_password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@ -445,6 +462,14 @@ async fn admin_reset_password(
));
}
// Validate new password strength
if let Err(msg) = validate_password_strength(&req.new_password) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "weak_password", "message": msg } })),
));
}
let new_hash = hash_password(&req.new_password).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,