use axum::{ extract::{Form, Query, Request, State}, http::header, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, }; use serde::Deserialize; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Mutex; pub type Sessions = Arc>>; pub fn new_sessions() -> Sessions { Arc::new(Mutex::new(HashSet::new())) } // ── Middleware ────────────────────────────────────────────────────────────── pub async fn auth_middleware( State(state): State, request: Request, next: Next, ) -> Response { // Auth disabled when credentials are not configured. if state.admin_user.is_none() { return next.run(request).await; } let token = session_cookie(request.headers()); let authenticated = match token { Some(t) => state.sessions.lock().await.contains(&t), None => false, }; if authenticated { next.run(request).await } else { let path = request .uri() .path_and_query() .map(|p| p.as_str()) .unwrap_or("/"); Redirect::to(&format!("/login?next={}", safe_path(path))).into_response() } } fn session_cookie(headers: &axum::http::HeaderMap) -> Option { headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|cookies| { cookies.split(';').find_map(|c| { let c = c.trim(); c.strip_prefix("session=").map(|s| s.to_string()) }) }) } /// Reject open-redirect: only allow relative paths. fn safe_path(next: &str) -> String { if next.starts_with('/') && !next.starts_with("//") { next.to_string() } else { "/".to_string() } } // ── Login / Logout ────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct NextParam { next: Option, } #[derive(Deserialize)] pub struct LoginForm { username: String, password: String, next: Option, } pub async fn login_page(Query(params): Query) -> impl IntoResponse { let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into()); Html(login_html(&next, false)) } pub async fn handle_login( State(state): State, Form(form): Form, ) -> Response { let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); let valid = state.admin_user.as_deref() == Some(form.username.as_str()) && state.admin_pass.as_deref() == Some(form.password.as_str()); if !valid { return Html(login_html(&next, true)).into_response(); } let token = uuid::Uuid::new_v4().to_string(); state.sessions.lock().await.insert(token.clone()); let cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/", token); let mut response = Redirect::to(&next).into_response(); response .headers_mut() .insert(header::SET_COOKIE, cookie.parse().unwrap()); response } pub async fn handle_logout( State(state): State, headers: axum::http::HeaderMap, ) -> Response { if let Some(token) = session_cookie(&headers) { state.sessions.lock().await.remove(&token); } let mut response = Redirect::to("/login").into_response(); response.headers_mut().insert( header::SET_COOKIE, "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" .parse() .unwrap(), ); response } // ── Login page HTML ───────────────────────────────────────────────────────── fn login_html(next: &str, error: bool) -> String { let error_html = if error { r#"

Invalid username or password.

"# } else { "" }; format!( r#" HIY — Login

HostItYourself

{error_html}
"# ) }