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
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:
@ -20,5 +20,6 @@ pub mod session;
|
||||
// Commonly re-exported types
|
||||
pub use jwt::{AccessClaims, JwtError};
|
||||
pub use password::{hash_password, verify_password, PasswordError};
|
||||
pub use password::validate_password_strength;
|
||||
pub use rbac::{AuthConfig, AuthUser, UserRole};
|
||||
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};
|
||||
|
||||
@ -67,6 +67,34 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate password strength against minimum requirements.
|
||||
///
|
||||
/// Requirements:
|
||||
/// - Minimum 8 characters
|
||||
/// - At least one uppercase letter
|
||||
/// - At least one lowercase letter
|
||||
/// - At least one digit
|
||||
/// - At least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)
|
||||
pub fn validate_password_strength(password: &str) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters".to_string());
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
return Err("Password must contain at least one uppercase letter".to_string());
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
return Err("Password must contain at least one lowercase letter".to_string());
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Err("Password must contain at least one digit".to_string());
|
||||
}
|
||||
let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||
if !password.chars().any(|c| special_chars.contains(c)) {
|
||||
return Err("Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -30,6 +30,8 @@ pub enum SessionError {
|
||||
MfaRequired,
|
||||
#[error("Invalid MFA code")]
|
||||
InvalidMfaCode,
|
||||
#[error("Account locked due to too many failed attempts")]
|
||||
AccountLocked,
|
||||
#[error("JWT error: {0}")]
|
||||
Jwt(#[from] JwtError),
|
||||
#[error("Refresh token error: {0}")]
|
||||
@ -78,6 +80,8 @@ struct DbUser {
|
||||
mfa_enabled: bool,
|
||||
is_active: bool,
|
||||
force_password_reset: bool,
|
||||
failed_login_attempts: i32,
|
||||
locked_until: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Login request payload.
|
||||
@ -110,7 +114,8 @@ pub async fn login(
|
||||
let user: Option<DbUser> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, display_name, role, auth_provider,
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE username = $1 AND auth_provider = 'local'
|
||||
"#,
|
||||
@ -129,12 +134,43 @@ pub async fn login(
|
||||
},
|
||||
};
|
||||
|
||||
// 2a. Check if account is locked due to too many failed attempts
|
||||
if let Some(locked_until) = user.locked_until {
|
||||
if locked_until > Utc::now() {
|
||||
tracing::warn!(username = %req.username, "Login blocked: account locked until {}", locked_until);
|
||||
return Err(SessionError::AccountLocked);
|
||||
}
|
||||
// Lockout period has expired — reset counters
|
||||
sqlx::query("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1")
|
||||
.bind(user.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 2. Verify password
|
||||
let hash = user.password_hash.as_deref().unwrap_or("");
|
||||
let valid = password::verify_password(&req.password, hash).unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid password");
|
||||
// Increment failed login attempts
|
||||
let new_attempts = user.failed_login_attempts + 1;
|
||||
if new_attempts >= 5 {
|
||||
let lock_until = Utc::now() + chrono::Duration::minutes(30);
|
||||
sqlx::query("UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3")
|
||||
.bind(new_attempts)
|
||||
.bind(lock_until)
|
||||
.bind(user.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts);
|
||||
} else {
|
||||
sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2")
|
||||
.bind(new_attempts)
|
||||
.bind(user.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid password (attempt {})", new_attempts);
|
||||
return Err(SessionError::InvalidCredentials);
|
||||
}
|
||||
|
||||
@ -175,7 +211,7 @@ pub async fn login(
|
||||
let raw_refresh = refresh::issue(pool, user.id, user_agent, ip_address).await?;
|
||||
|
||||
// 6. Update last_login_at
|
||||
sqlx::query("UPDATE users SET last_login_at = $1 WHERE id = $2")
|
||||
sqlx::query("UPDATE users SET last_login_at = $1, failed_login_attempts = 0, locked_until = NULL WHERE id = $2")
|
||||
.bind(Utc::now())
|
||||
.bind(user.id)
|
||||
.execute(pool)
|
||||
@ -216,7 +252,8 @@ 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, force_password_reset
|
||||
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
|
||||
@ -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>)> {
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user