use argon2::{ password_hash::{PasswordHasher, PasswordVerifier}, Argon2, }; use rouille::{input::json_input, Request, Response}; use serde::Deserialize; use serde_json::json; use std::sync::Arc; use super::helpers::{ client_ip, get_conn, get_session_cookie, json_error, require_auth, user_agent, }; use super::AppState; #[derive(Debug, Deserialize)] struct LoginForm { username: String, password: String, } pub fn login(request: &Request, state: &Arc) -> Response { let form: LoginForm = match json_input(request) { Ok(f) => f, Err(_) => return json_error(400, "Invalid JSON"), }; let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; let user = match conn.query_opt( "SELECT id, username, email, role, password_hash, is_active, mfa_enabled, \ failed_login_attempts, locked_until FROM users WHERE username = $1", &[&form.username], ) { Ok(Some(row)) => { let is_active: bool = row.get(5); if !is_active { return json_error(401, "Invalid credentials"); } let locked_until: Option = row.get(8); if locked_until.is_some_and(|locked| locked > chrono::Utc::now().naive_utc()) { return json_error(423, "Account temporarily locked"); } let password_hash: String = row.get(4); let is_valid = match argon2::password_hash::PasswordHash::new(&password_hash) { Ok(h) => Argon2::default() .verify_password(form.password.as_bytes(), &h) .is_ok(), Err(_) => false, }; if !is_valid { let user_id: i32 = row.get(0); let mut attempts: i32 = row.get(7); attempts += 1; let mut new_locked_until = locked_until; if attempts >= 5 { new_locked_until = Some( chrono::Utc::now().naive_utc() + chrono::Duration::hours(1), ); attempts = 0; } let _ = conn.execute( "UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3", &[&attempts, &new_locked_until, &user_id], ); return json_error(401, "Invalid credentials"); } ( row.get::<_, i32>(0), row.get::<_, String>(1), row.get::<_, String>(2), row.get::<_, String>(3), ) } Ok(None) => return json_error(401, "Invalid credentials"), Err(_) => return json_error(500, "Database error"), }; let (user_id, username, email, role) = user; let session_id = uuid::Uuid::new_v4().to_string(); let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::seconds(1800); let _ = conn.execute( "INSERT INTO sessions (id, user_id, expires_at, user_agent) VALUES ($1, $2, $3, $4)", &[&session_id, &user_id, &expires_at, &"unknown"], ); let _ = conn.execute( "UPDATE users SET failed_login_attempts = 0, locked_until = NULL, \ last_login = CURRENT_TIMESTAMP WHERE id = $1", &[&user_id], ); let ip = client_ip(request); let ua = user_agent(request); let _ = conn.execute( "INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \ VALUES ($1, $2, 'login', $3, $4, '{}'::jsonb)", &[&user_id, &session_id, &ip, &ua], ); Response::json(&json!({ "user": { "id": user_id, "username": username, "email": email, "role": role }, "session_id": session_id })) .with_additional_header( "Set-Cookie", format!( "session_id={}; Path=/; HttpOnly; SameSite=Strict; Max-Age=1800", session_id ), ) } pub fn logout(request: &Request, state: &Arc) -> Response { let session_cookie = match get_session_cookie(request) { Some(c) => c, None => return json_error(401, "Authentication required"), }; let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; if let Ok(Some(row)) = conn.query_opt( "SELECT user_id FROM sessions WHERE id = $1", &[&session_cookie], ) { let uid: i32 = row.get(0); let ip = client_ip(request); let ua = user_agent(request); let _ = conn.execute( "INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \ VALUES ($1, $2, 'logout', $3, $4, '{}'::jsonb)", &[&uid, &session_cookie, &ip, &ua], ); } let _ = conn.execute("DELETE FROM sessions WHERE id = $1", &[&session_cookie]); Response::json(&json!({"message": "Logged out"})).with_additional_header( "Set-Cookie", "session_id=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", ) } pub fn me(request: &Request, state: &Arc) -> Response { let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; match require_auth(request, &mut conn) { Ok(user) => Response::json(&json!({ "id": user.id, "username": user.username, "email": user.email, "role": user.role, })), Err(e) => e, } } pub fn change_password(request: &Request, state: &Arc) -> Response { let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; let user = match require_auth(request, &mut conn) { Ok(u) => u, Err(e) => return e, }; let form: sap_sync_backend::models::PasswordChangeForm = match json_input(request) { Ok(f) => f, Err(_) => return json_error(400, "Invalid JSON"), }; let current_hash: String = match conn.query_opt( "SELECT password_hash FROM users WHERE id = $1", &[&user.id], ) { Ok(Some(row)) => row.get(0), _ => return json_error(500, "Database error"), }; let is_valid = match argon2::password_hash::PasswordHash::new(¤t_hash) { Ok(h) => Argon2::default() .verify_password(form.current_password.as_bytes(), &h) .is_ok(), Err(_) => false, }; if !is_valid { return json_error(401, "Current password is incorrect"); } let salt = argon2::password_hash::SaltString::generate(rand::thread_rng()); let new_hash = match Argon2::default().hash_password(form.new_password.as_bytes(), &salt) { Ok(h) => h.to_string(), Err(_) => return json_error(500, "Failed to hash password"), }; let _ = conn.execute( "UPDATE users SET password_hash = $1 WHERE id = $2", &[&new_hash, &user.id], ); Response::json(&json!({"message": "Password changed successfully"})) } pub fn mfa_setup(request: &Request, state: &Arc) -> Response { let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; let user = match require_auth(request, &mut conn) { Ok(u) => u, Err(e) => return e, }; use rand::RngCore; let mut secret_bytes = [0u8; 20]; rand::thread_rng().fill_bytes(&mut secret_bytes); let secret_b32 = super::base32_encode(&secret_bytes); let _ = conn.execute( "UPDATE users SET mfa_secret = $1 WHERE id = $2", &[&secret_b32, &user.id], ); // Generate and store hashed backup codes let mut backup_codes: Vec = Vec::new(); for _ in 0..8 { let code = format!("{:08}", rand::thread_rng().next_u32() % 100_000_000); let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng()); if let Ok(hash) = Argon2::default().hash_password(code.as_bytes(), &salt) { let _ = conn.execute( "INSERT INTO mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)", &[&user.id, &hash.to_string()], ); } backup_codes.push(code); } let qr_url = format!( "otpauth://totp/SAP-PLEX-SYNC:{}?secret={}&issuer=SAP-PLEX-SYNC&digits=6&period=30", user.username, secret_b32 ); Response::json(&json!({ "method": "totp", "secret": secret_b32, "qr_code_url": qr_url, "backup_codes": backup_codes, })) } pub fn mfa_verify(request: &Request, state: &Arc) -> Response { #[derive(Deserialize)] struct MfaVerifyForm { code: String, } let form: MfaVerifyForm = match json_input(request) { Ok(f) => f, Err(_) => return json_error(400, "Invalid JSON"), }; let mut conn = match get_conn(state) { Ok(c) => c, Err(e) => return e, }; let session_cookie = match get_session_cookie(request) { Some(c) => c, None => return json_error(401, "Not authenticated"), }; let row = match conn.query_opt( "SELECT u.id, u.mfa_secret FROM users u \ JOIN sessions s ON u.id = s.user_id \ WHERE s.id = $1 AND s.expires_at > CURRENT_TIMESTAMP", &[&session_cookie], ) { Ok(Some(r)) => r, Ok(None) => return json_error(401, "Invalid session"), Err(_) => return json_error(500, "Database error"), }; let user_id: i32 = row.get("id"); let mfa_secret: Option = row.get("mfa_secret"); let secret = match mfa_secret { Some(s) => s, None => return json_error(400, "MFA not set up"), }; let secret_bytes = match super::base32_decode(&secret) { Some(b) => b, None => return json_error(500, "Invalid MFA secret"), }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let expected = totp_lite::totp_custom::(30, 6, &secret_bytes, now); if form.code == expected { let _ = conn.execute( "UPDATE users SET mfa_enabled = TRUE WHERE id = $1", &[&user_id], ); Response::json(&json!({"message": "MFA enabled successfully"})) } else { json_error(400, "Invalid verification code") } }