use axum::{ extract::{Form, Query, Request, State}, http::{header, HeaderMap, StatusCode}, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, }; use serde::Deserialize; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; /// token → user_id pub type Sessions = Arc>>; pub fn new_sessions() -> Sessions { Arc::new(Mutex::new(HashMap::new())) } // ── Helpers ───────────────────────────────────────────────────────────────── pub fn session_cookie_header(token: &str, domain_suffix: &str) -> String { let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") { String::new() } else { format!("; Domain=.{}", domain_suffix) }; format!( "session={}; HttpOnly; SameSite=Lax; Path={}{}", token, "/", domain ) } fn clear_session_cookie(domain_suffix: &str) -> String { let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") { String::new() } else { format!("; Domain=.{}", domain_suffix) }; format!( "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", domain ) } pub fn get_session_cookie(headers: &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()) }) }) } /// Resolve the user_id for the current request, if logged in. pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Option { let token = get_session_cookie(headers)?; state.sessions.lock().await.get(&token).cloned() } /// Reject open-redirect: allow relative paths or absolute URLs on the same domain. fn safe_redirect(next: &str, domain: &str) -> String { if next.starts_with('/') && !next.starts_with("//") { return next.to_string(); } // Allow https:// URLs whose host is the domain or a subdomain of it. for prefix in &["https://", "http://"] { if let Some(rest) = next.strip_prefix(prefix) { let host = rest.split('/').next().unwrap_or(""); if host == domain || host.ends_with(&format!(".{}", domain)) { return next.to_string(); } } } "/".to_string() } /// Reject open-redirect: only allow relative paths (used for admin-UI redirects). fn safe_path(next: &str) -> String { safe_redirect(next, "") } // ── Control-plane middleware (admin users only) ─────────────────────────────── pub async fn auth_middleware( State(state): State, request: Request, next: Next, ) -> Response { // Auth disabled when credentials are not configured (dev mode). if state.admin_user.is_none() && !state.auth_enabled { return next.run(request).await; } let user_id = current_user_id(&state, request.headers()).await; let path = request .uri() .path_and_query() .map(|p| p.as_str()) .unwrap_or("/"); let uid = match user_id { None => { // Not logged in → send to login with return path. return Redirect::to(&format!("/login?next={}", safe_path(path))).into_response(); } Some(uid) => uid, }; let is_admin = sqlx::query_scalar::<_, i64>("SELECT is_admin FROM users WHERE id = ?") .bind(&uid) .fetch_optional(&state.db) .await .unwrap_or(None) .map(|v| v != 0) .unwrap_or(false); if is_admin { next.run(request).await } else { // Logged in but not an admin → access denied, no redirect loop. Redirect::to("/denied").into_response() } } // ── /auth/verify — called by Caddy forward_auth for every app request ──────── pub async fn verify( State(state): State, headers: HeaderMap, ) -> Response { let user_id = current_user_id(&state, &headers).await; let uid = match user_id { Some(u) => u, None => { // Not logged in — redirect to login, passing the original URL as `next`. let host = headers .get("x-forwarded-host") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let uri = headers .get("x-forwarded-uri") .and_then(|v| v.to_str().ok()) .unwrap_or("/"); let proto = headers .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .unwrap_or("https"); let next_url = if host.is_empty() { "/".to_string() } else { format!("{}://{}{}", proto, host, uri) }; let login_url = format!( "https://{}/login?next={}", state.domain_suffix, urlencoding_simple(&next_url) ); return Redirect::to(&login_url).into_response(); } }; // Resolve app_id from the forwarded host header. let host = headers .get("x-forwarded-host") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let app_id = host .strip_suffix(&format!(".{}", state.domain_suffix)) .unwrap_or(host); if app_id.is_empty() { return StatusCode::FORBIDDEN.into_response(); } // Admin users have access to all apps. let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?") .bind(&uid) .fetch_optional(&state.db) .await .unwrap_or(None) .unwrap_or(0); if is_admin != 0 { return StatusCode::OK.into_response(); } // Regular users: check user_apps grant. let granted: Option = sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?") .bind(&uid) .bind(app_id) .fetch_optional(&state.db) .await .unwrap_or(None); if granted.is_some() { StatusCode::OK.into_response() } else { // Logged in but not granted — redirect to a "not authorised" page. let page = format!( "https://{}/denied?app={}", state.domain_suffix, app_id ); Redirect::to(&page).into_response() } } /// Minimal percent-encoding for use in a query-string value. fn urlencoding_simple(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => { out.push(b as char); } _ => { out.push('%'); out.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0')); out.push(char::from_digit((b & 0xf) as u32, 16).unwrap_or('0')); } } } out } // ── Login / Logout ────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct NextParam { pub next: Option, } #[derive(Deserialize)] pub struct LoginForm { username: String, password: String, next: Option, } pub async fn login_page( State(state): State, headers: HeaderMap, Query(params): Query, ) -> Response { // Already logged in → redirect. if current_user_id(&state, &headers).await.is_some() { let next = params.next.as_deref().map(|n| safe_redirect(n, &state.domain_suffix)).unwrap_or_else(|| "/".into()); return Redirect::to(&next).into_response(); } let next = params.next.map(|s| safe_redirect(&s, &state.domain_suffix)).unwrap_or_else(|| "/".into()); Html(login_html(&next, None)).into_response() } pub async fn handle_login( State(state): State, Form(form): Form, ) -> Response { let next = form.next.as_deref().map(|n| safe_redirect(n, &state.domain_suffix)).unwrap_or_else(|| "/".into()); // --- env-var bootstrap path (used before any DB user exists) --- let using_bootstrap = matches!( (&state.admin_user, &state.admin_pass), (Some(_), Some(_)) ) && !state.auth_enabled; if using_bootstrap { let ok = state.admin_user.as_deref() == Some(form.username.as_str()) && state.admin_pass.as_deref() == Some(form.password.as_str()); if !ok { return Html(login_html(&next, Some("Invalid username or password."))).into_response(); } return set_session_for_bootstrap(&state, &next).await; } // --- DB path --- let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = ?") .bind(&form.username) .fetch_optional(&state.db) .await .unwrap_or(None); let user = match user { Some(u) => u, None => { return Html(login_html(&next, Some("Invalid username or password."))).into_response(); } }; let ok = bcrypt::verify(&form.password, &user.password_hash).unwrap_or(false); if !ok { return Html(login_html(&next, Some("Invalid username or password."))).into_response(); } let token = uuid::Uuid::new_v4().to_string(); state.sessions.lock().await.insert(token.clone(), user.id); let cookie = session_cookie_header(&token, &state.domain_suffix); let mut response = Redirect::to(&next).into_response(); response .headers_mut() .insert(header::SET_COOKIE, cookie.parse().unwrap()); response } async fn set_session_for_bootstrap(state: &crate::AppState, next: &str) -> Response { let token = uuid::Uuid::new_v4().to_string(); // Bootstrap sessions store "bootstrap" as the user_id sentinel. state.sessions.lock().await.insert(token.clone(), "bootstrap".to_string()); let cookie = session_cookie_header(&token, &state.domain_suffix); 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: HeaderMap, ) -> Response { if let Some(token) = get_session_cookie(&headers) { state.sessions.lock().await.remove(&token); } let mut response = Redirect::to("/login").into_response(); response.headers_mut().insert( header::SET_COOKIE, clear_session_cookie(&state.domain_suffix).parse().unwrap(), ); response } /// "Access denied" page — shown when logged in but not granted the app. pub async fn denied_page( Query(params): Query>, ) -> impl IntoResponse { let app = params.get("app").map(|s| s.as_str()).unwrap_or("this app"); Html(format!( r#"Access denied

Access denied

You do not have access to {app}.

Contact your administrator to request access.

← Sign in with a different account
"# )) } // ── Login page HTML ───────────────────────────────────────────────────────── fn login_html(next: &str, error: Option<&str>) -> String { let error_html = match error { Some(msg) => format!(r#"

{}

"#, msg), None => String::new(), }; format!( r#" HIY — Login

HostItYourself

{error_html}
"# ) }