use argon2::{ Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::{SaltString, rand_core::OsRng}, }; use axum::{ extract::{FromRequestParts, Request, State}, http::{StatusCode, request::Parts}, middleware::Next, response::{IntoResponse, Redirect, Response}, }; use rusqlite::params; use serde::Deserialize; use std::collections::HashMap; use std::sync::Mutex as StdMutex; use std::time::Instant; use uuid::Uuid; use crate::models::Account; use crate::routes::AppState; #[derive(Debug, Deserialize)] pub struct LoginForm { pub username: String, pub password: String, } const SESSION_COOKIE: &str = "mcui_session"; /// Simple in-memory rate limiter for login attempts. /// Tracks failed attempts per IP address with a sliding window. const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10; static LOGIN_ATTEMPTS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| StdMutex::new(HashMap::new())); fn check_rate_limit(ip: &str) -> bool { let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); let now = Instant::now(); // Clean stale entries attempts.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < RATE_LIMIT_WINDOW_SECS); let entry = attempts.entry(ip.to_string()).or_insert((0, now)); if entry.0 >= RATE_LIMIT_MAX_ATTEMPTS { false } else { entry.0 += 1; entry.1 = now; true } } fn reset_rate_limit(ip: &str) { let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); attempts.remove(ip); } pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; Ok(hash.to_string()) } pub fn verify_password(password: &str, hash: &str) -> Result { let parsed = PasswordHash::new(hash)?; Ok(Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok()) } pub async fn authenticate( State(state): State, axum::Form(form): axum::Form, ) -> Result { // Rate limiting: check by client IP // (We use a placeholder as we can't easily get the real IP without a proxy-aware extractor; // a reverse proxy should set X-Forwarded-For.) let rate_limit_ip = "global"; // simplified; in production use X-Forwarded-For if !check_rate_limit(rate_limit_ip) { return Err(( StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Please try again later.".into(), )); } let conn = state.db.conn(); let account = conn .query_row( "SELECT id, username, is_superuser, password_change_required FROM accounts WHERE username = ?1", params![form.username], |row| { Ok(Account { id: row.get(0)?, username: row.get(1)?, is_superuser: row.get::<_, i32>(2)? != 0, password_change_required: row.get::<_, i32>(3)? != 0, }) }, ) .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid username or password".into()))?; let hash: String = conn .query_row( "SELECT password FROM accounts WHERE id = ?1", params![account.id], |row| row.get(0), ) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()))?; if !verify_password(&form.password, &hash).unwrap_or(false) { return Err(( StatusCode::UNAUTHORIZED, "Invalid username or password".into(), )); } // Successful login — reset rate limit reset_rate_limit(rate_limit_ip); let session_id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", params![session_id, account.id], ) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; drop(conn); // Set Secure flag when not on localhost (for when behind a reverse proxy with TLS) // The Max-Age is deliberately set to 24h (86400s) as a first line of defense. // Server-side enforcement happens in require_auth via the created_at check. let cookie_value = format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Strict"); let redirect = if account.password_change_required { Redirect::to("/change-password") } else { Redirect::to("/") }; Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) } /// Number of seconds after which a session expires server-side. const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours pub async fn require_auth( State(state): State, mut request: Request, next: Next, ) -> Result { let raw_cookies = request .headers() .get(axum::http::header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or("[no cookie header]") .to_string(); let session_id = raw_cookies.split(';').map(|s| s.trim()).find_map(|cookie| { if let Some((name, value)) = cookie.split_once('=') { let n = name.trim(); if n == SESSION_COOKIE { Some(value.trim().to_string()) } else { None } } else { None } }); match session_id { Some(id) => { let account = state .db .conn() .query_row( "SELECT a.id, a.username, a.is_superuser, a.password_change_required FROM sessions s JOIN accounts a ON s.account_id = a.id WHERE s.id = ?1 AND (strftime('%s','now') - strftime('%s', s.created_at)) <= ?2", params![id, SESSION_MAX_AGE_SECS], |row| { Ok(Account { id: row.get(0)?, username: row.get(1)?, is_superuser: row.get::<_, i32>(2)? != 0, password_change_required: row.get::<_, i32>(3)? != 0, }) }, ) .ok(); if let Some(account) = account { tracing::debug!("Auth OK for {}", account.username); request.extensions_mut().insert(account); Ok(next.run(request).await) } else { // Session expired or not found — clean up stale session let _ = state.db.conn().execute( "DELETE FROM sessions WHERE id = ?1", params![id], ); tracing::warn!("Session {id} expired or not found"); Err(Redirect::to("/login").into_response()) } } None => { tracing::warn!("No session cookie found (raw cookies: {raw_cookies})"); Err(Redirect::to("/login").into_response()) } } } #[derive(Clone)] pub struct CurrentUser(pub Account); impl FromRequestParts for CurrentUser where S: Send + Sync, { type Rejection = (StatusCode, &'static str); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { parts .extensions .get::() .cloned() .map(CurrentUser) .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated")) } } pub async fn logout( State(state): State, CurrentUser(_account): CurrentUser, headers: axum::http::HeaderMap, ) -> impl IntoResponse { if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) && let Ok(cookie_str) = cookie_header.to_str() { for part in cookie_str.split(';') { let part = part.trim(); if let Some((name, value)) = part.split_once('=') && name.trim() == SESSION_COOKIE { let _ = state .db .conn() .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]); } } } let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0"); ( [(axum::http::header::SET_COOKIE, clear_cookie)], Redirect::to("/login"), ) }