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 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"; 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 { 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(), )); } 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); let cookie_value = format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); let redirect = if account.password_change_required { Redirect::to("/change-password") } else { Redirect::to("/") }; Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) } 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", params![id], |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 { tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})"); 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, 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"), ) }