From ae7ef10b67737f08a86743f687bca30a12e9e17a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 19:38:03 +0200 Subject: first stable --- src/auth.rs | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/auth.rs (limited to 'src/auth.rs') diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..8fa62ec --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,194 @@ +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 cookies = request + .headers() + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| { + if let Some((name, value)) = cookie.split_once('=') { + if name.trim() == 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 { + request.extensions_mut().insert(account); + Ok(next.run(request).await) + } else { + Err(Redirect::to("/login").into_response()) + } + } + None => 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) { + if 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('=') { + if 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"), + ) +} -- cgit v1.2.3