diff --git a/crates/pm-auth/src/lib.rs b/crates/pm-auth/src/lib.rs index 8ffe325..0990220 100644 --- a/crates/pm-auth/src/lib.rs +++ b/crates/pm-auth/src/lib.rs @@ -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}; diff --git a/crates/pm-auth/src/password.rs b/crates/pm-auth/src/password.rs index c18491d..6366f43 100644 --- a/crates/pm-auth/src/password.rs +++ b/crates/pm-auth/src/password.rs @@ -67,6 +67,34 @@ pub fn verify_password(password: &str, hash: &str) -> Result?) +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::*; diff --git a/crates/pm-auth/src/session.rs b/crates/pm-auth/src/session.rs index eb5404c..15918f7 100644 --- a/crates/pm-auth/src/session.rs +++ b/crates/pm-auth/src/session.rs @@ -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>, } /// Login request payload. @@ -110,7 +114,8 @@ 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, 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 "#, ) diff --git a/crates/pm-web/src/routes/auth.rs b/crates/pm-web/src/routes/auth.rs index 10ecfaa..8aba4fd 100644 --- a/crates/pm-web/src/routes/auth.rs +++ b/crates/pm-web/src/routes/auth.rs @@ -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 { .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, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // 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, 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, (StatusCode, Json)> { diff --git a/crates/pm-web/src/routes/users.rs b/crates/pm-web/src/routes/users.rs index 68e9cac..1bc73df 100644 --- a/crates/pm-web/src/routes/users.rs +++ b/crates/pm-web/src/routes/users.rs @@ -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, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e23b98..01f7299 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,8 +12,10 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", + "@types/qrcode": "^1.5.6", "axios": "^1.9.0", "jszip": "^3.10.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.5.3", @@ -1772,6 +1774,14 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "25.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz", + "integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -1782,6 +1792,14 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2086,11 +2104,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2222,6 +2247,14 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001790", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", @@ -2258,6 +2291,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2270,7 +2313,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2281,8 +2323,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2381,6 +2422,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2395,6 +2444,11 @@ "node": ">=0.4.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2423,6 +2477,11 @@ "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2904,6 +2963,14 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3103,6 +3170,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3408,6 +3483,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -3445,7 +3528,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -3489,6 +3571,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -3563,6 +3653,22 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -3661,6 +3767,19 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3755,6 +3874,11 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3811,6 +3935,30 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3904,6 +4052,11 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4037,6 +4190,11 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4046,6 +4204,24 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4069,6 +4245,87 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c08a8b0..924ff1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,8 +15,10 @@ "@emotion/styled": "^11.14.0", "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", + "@types/qrcode": "^1.5.6", "axios": "^1.9.0", "jszip": "^3.10.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.5.3", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5c04505..435c2f5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -101,6 +101,9 @@ export const authApi = { logout: (refreshToken: string) => apiClient.post('/auth/logout', { refresh_token: refreshToken }), + forceChangePassword: (username: string, currentPassword: string, newPassword: string) => + apiClient.post('/auth/force-change-password', { username, current_password: currentPassword, new_password: newPassword }), + getMfaSetup: () => apiClient.get('/auth/mfa/setup'), diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 03caf97..10f5e92 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -3,8 +3,12 @@ import { useNavigate } from 'react-router-dom' import { Box, Button, Container, TextField, Typography, Alert, CircularProgress, Paper, InputAdornment, IconButton, + List, ListItem, ListItemIcon, ListItemText, } from '@mui/material' -import { Visibility, VisibilityOff } from '@mui/icons-material' +import { + Visibility, VisibilityOff, + Check as CheckIcon, Close as CloseIcon, +} from '@mui/icons-material' import { authApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { User } from '../types' @@ -32,6 +36,16 @@ function getErrorMessage(err: unknown): string { return 'MFA_REQUIRED' // sentinel — caller checks this } + // Password reset required + if (code === 'password_reset_required') { + return 'PASSWORD_RESET_REQUIRED' + } + + // Account locked + if (code === 'account_locked') { + return 'ACCOUNT_LOCKED' + } + // Account disabled if (code === 'account_disabled') { return 'This account has been disabled. Contact your administrator.' @@ -56,6 +70,21 @@ function getErrorMessage(err: unknown): string { return 'Login failed. Please try again.' } +/** Password strength checker */ +function checkPasswordStrength(password: string) { + return { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + digit: /[0-9]/.test(password), + special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password), + } +} + +function isPasswordValid(checks: ReturnType) { + return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special +} + export default function LoginPage() { const navigate = useNavigate() const { setTokens, setUser } = useAuthStore() @@ -65,9 +94,20 @@ export default function LoginPage() { const [totpCode, setTotpCode] = useState('') const [showPassword, setShowPassword] = useState(false) const [needsMfa, setNeedsMfa] = useState(false) + const [forcePasswordReset, setForcePasswordReset] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // Force change password state + const [newPassword, setNewPassword] = useState('') + const [confirmNewPassword, setConfirmNewPassword] = useState('') + const [showNewPassword, setShowNewPassword] = useState(false) + const [passwordChanged, setPasswordChanged] = useState(false) + + const pwChecks = checkPasswordStrength(newPassword) + const pwValid = isPasswordValid(pwChecks) + const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) @@ -84,6 +124,11 @@ export default function LoginPage() { if (message === 'MFA_REQUIRED') { setNeedsMfa(true) setError('Please enter your MFA code.') + } else if (message === 'PASSWORD_RESET_REQUIRED') { + setForcePasswordReset(true) + setError('You must change your password before logging in.') + } else if (message === 'ACCOUNT_LOCKED') { + setError('Account locked due to too many failed login attempts. Please try again in 30 minutes.') } else { setError(message) } @@ -92,6 +137,44 @@ export default function LoginPage() { } } + const handleForceChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + if (!pwValid || pwMismatch) return + setLoading(true) + setError(null) + + try { + await authApi.forceChangePassword(username, password, newPassword) + setPasswordChanged(true) + setForcePasswordReset(false) + setNewPassword('') + setConfirmNewPassword('') + setPassword('') + } catch (err: unknown) { + const axiosErr = err as { response?: { data?: { error?: { code?: string; message?: string } } } } + const code = axiosErr.response?.data?.error?.code + const msg = axiosErr.response?.data?.error?.message + if (code === 'weak_password') { + setError(msg || 'Password does not meet strength requirements.') + } else if (code === 'invalid_credentials') { + setError('Invalid username or password.') + } else { + setError(msg || 'Failed to change password. Please try again.') + } + } finally { + setLoading(false) + } + } + + const handleBackToLogin = () => { + setForcePasswordReset(false) + setPasswordChanged(false) + setError(null) + setPassword('') + setNewPassword('') + setConfirmNewPassword('') + } + return ( @@ -101,7 +184,7 @@ export default function LoginPage() { {error && ( setError(null)} > @@ -109,42 +192,139 @@ export default function LoginPage() { )} - - setUsername(e.target.value)} - disabled={loading} required autoFocus - /> - setPassword(e.target.value)} disabled={loading} required - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} edge="end"> - {showPassword ? : } - - - ), - }} - /> - {needsMfa && ( + {passwordChanged ? ( + + + Password changed successfully! Please log in with your new password. + + + + ) : forcePasswordReset ? ( + + + Change Your Password + + + Your password has expired and must be changed before you can log in. + setTotpCode(e.target.value)} - disabled={loading} required autoFocus - helperText="Enter the 6-digit code from your authenticator app" + fullWidth margin="normal" label="Username" + value={username} InputProps={{ readOnly: true }} /> - )} - - + + setNewPassword(e.target.value)} + disabled={loading} required + InputProps={{ + endAdornment: ( + + setShowNewPassword(!showNewPassword)} edge="end"> + {showNewPassword ? : } + + + ), + }} + /> + {newPassword && ( + + + + + {pwChecks.length ? : } + + + + + + {pwChecks.uppercase ? : } + + + + + + {pwChecks.lowercase ? : } + + + + + + {pwChecks.digit ? : } + + + + + + {pwChecks.special ? : } + + + + + + )} + setConfirmNewPassword(e.target.value)} + disabled={loading} required + error={pwMismatch} + helperText={pwMismatch ? 'Passwords do not match' : ''} + /> + + + ) : ( + + setUsername(e.target.value)} + disabled={loading} required autoFocus + /> + setPassword(e.target.value)} disabled={loading} required + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} edge="end"> + {showPassword ? : } + + + ), + }} + /> + {needsMfa && ( + setTotpCode(e.target.value)} + disabled={loading} required autoFocus + helperText="Enter the 6-digit code from your authenticator app" + /> + )} + + + )} ) diff --git a/frontend/src/pages/MfaSetupPage.tsx b/frontend/src/pages/MfaSetupPage.tsx index 1aaa02d..b04a53e 100644 --- a/frontend/src/pages/MfaSetupPage.tsx +++ b/frontend/src/pages/MfaSetupPage.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useState } from 'react' import { Box, Button, Container, TextField, Typography, Alert, CircularProgress, Paper, Stepper, Step, StepLabel, + IconButton, Tooltip, Snackbar, } from '@mui/material' +import { ContentCopy as CopyIcon } from '@mui/icons-material' +import QRCode from 'qrcode' import { authApi } from '../api/client' const STEPS = ['Get your QR code', 'Verify code', 'Done'] @@ -13,13 +16,35 @@ export default function MfaSetupPage() { const [code, setCode] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [qrDataUrl, setQrDataUrl] = useState(null) + const [copied, setCopied] = useState(false) useEffect(() => { authApi.getMfaSetup() - .then((res) => setSetup(res.data)) + .then((res) => { + setSetup(res.data) + // Generate QR code from otpauth URI + if (res.data.otp_uri) { + QRCode.toDataURL(res.data.otp_uri, { + width: 256, + margin: 2, + color: { dark: '#000000', light: '#ffffff' }, + }) + .then((url) => setQrDataUrl(url)) + .catch(() => setError('Failed to generate QR code.')) + } + }) .catch(() => setError('Failed to load MFA setup.')) }, []) + const handleCopySecret = () => { + if (setup?.secret_base32) { + navigator.clipboard.writeText(setup.secret_base32) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + const handleVerify = async (e: React.FormEvent) => { e.preventDefault() if (!setup) return @@ -48,14 +73,46 @@ export default function MfaSetupPage() { {step === 0 && setup && ( - Scan this URI in your authenticator app or enter the secret manually: + Scan this QR code in your authenticator app: - - {setup.otp_uri} - - - Manual entry secret: {setup.secret_base32} + {qrDataUrl ? ( + + MFA QR Code + + ) : ( + + + + )} + + If you can't scan the QR code, enter the secret manually: + + + {setup.secret_base32} + + + + + + + )} @@ -81,6 +138,15 @@ export default function MfaSetupPage() { )} + + setCopied(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + Secret copied to clipboard + ) } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index c018a5b..d986c2e 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -4,6 +4,7 @@ import { Box, Button, Card, CardContent, Chip, Container, Dialog, DialogActions, DialogContent, DialogTitle, Snackbar, Alert, TextField, Typography, InputAdornment, IconButton, + List, ListItem, ListItemIcon, ListItemText, } from '@mui/material' import { Person as PersonIcon, @@ -11,11 +12,27 @@ import { Visibility, VisibilityOff, VpnKey as MfaIcon, Save as SaveIcon, + Check as CheckIcon, Close as CloseIcon, } from '@mui/icons-material' import { useAuthStore } from '../store/authStore' import { usersApi } from '../api/client' import type { User } from '../types' +/** Password strength checker */ +function checkPasswordStrength(password: string) { + return { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + digit: /[0-9]/.test(password), + special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password), + } +} + +function isPasswordValid(checks: ReturnType) { + return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special +} + export default function ProfilePage() { const navigate = useNavigate() const { user, setUser } = useAuthStore() @@ -49,6 +66,10 @@ export default function ProfilePage() { const showSnack = (severity: 'success' | 'error', message: string) => setSnack({ open: true, severity, message }) + const pwChecks = checkPasswordStrength(newPw) + const pwValid = isPasswordValid(pwChecks) + const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw) + // ── Load current user on mount ────────────────────────────────────────── useEffect(() => { ;(async () => { @@ -87,6 +108,10 @@ export default function ProfilePage() { showSnack('error', 'New passwords do not match') return } + if (!pwValid) { + showSnack('error', 'Password does not meet strength requirements') + return + } setChangingPw(true) try { await usersApi.changePassword({ current_password: currentPw, new_password: newPw }) @@ -127,8 +152,6 @@ export default function ProfilePage() { ) } - const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw) - return ( My Profile @@ -217,6 +240,42 @@ export default function ProfilePage() { ), }} /> + {newPw && ( + + + + + {pwChecks.length ? : } + + + + + + {pwChecks.uppercase ? : } + + + + + + {pwChecks.lowercase ? : } + + + + + + {pwChecks.digit ? : } + + + + + + {pwChecks.special ? : } + + + + + + )} {changingPw ? 'Changing…' : 'Change Password'} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 968d543..ff03969 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -5,15 +5,74 @@ import { MenuItem, Paper, Select, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, Snackbar, Alert, InputAdornment, FormControl, InputLabel, + List, ListItem, ListItemIcon, ListItemText, } from '@mui/material' import { Add as AddIcon, Lock as LockIcon, Edit as EditIcon, VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon, + Check as CheckIcon, Close as CloseIcon, } from '@mui/icons-material' import { usersApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types' +/** Password strength checker */ +function checkPasswordStrength(password: string) { + return { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + digit: /[0-9]/.test(password), + special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password), + } +} + +function isPasswordValid(checks: ReturnType) { + return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special +} + +/** Reusable password strength checklist component */ +function PasswordStrengthIndicator({ password }: { password: string }) { + if (!password) return null + const checks = checkPasswordStrength(password) + return ( + + + + + {checks.length ? : } + + + + + + {checks.uppercase ? : } + + + + + + {checks.lowercase ? : } + + + + + + {checks.digit ? : } + + + + + + {checks.special ? : } + + + + + + ) +} + export default function UsersPage() { const currentUser = useAuthStore(s => s.user) const isAdmin = currentUser?.role === 'admin' @@ -56,6 +115,10 @@ export default function UsersPage() { const [deleteOpen, setDeleteOpen] = useState(false) const [deleteUser, setDeleteUser] = useState(null) + const addPwValid = isPasswordValid(checkPasswordStrength(addForm.password)) + const resetPwValid = isPasswordValid(checkPasswordStrength(resetForm.new_password)) + const resetPwMismatch = !!(resetForm.confirm_password && resetForm.new_password !== resetForm.confirm_password) + const load = async () => { setLoading(true) try { @@ -90,6 +153,10 @@ export default function UsersPage() { // ── Handlers ──────────────────────────────────────────────────────────────── const handleCreate = async () => { + if (!addPwValid) { + showSnack('error', 'Password does not meet strength requirements') + return + } try { await usersApi.create(addForm) setAddOpen(false) @@ -142,12 +209,12 @@ export default function UsersPage() { const handleResetSave = async () => { if (!resetUser) return - if (resetForm.new_password !== resetForm.confirm_password) { + if (resetPwMismatch) { showSnack('error', 'Passwords do not match') return } - if (resetForm.new_password.length < 8) { - showSnack('error', 'Password must be at least 8 characters') + if (!resetPwValid) { + showSnack('error', 'Password does not meet strength requirements') return } try { @@ -326,6 +393,7 @@ export default function UsersPage() { value={addForm.password} onChange={e => setAddForm({ ...addForm, password: e.target.value })} margin="normal" required /> + Role