use axum::http::{HeaderMap, header}; use loco_rs::prelude::*; use serde::Serialize; use std::{ collections::HashMap, sync::{LazyLock, Mutex}, }; use uuid::Uuid; const DEFAULT_ADMIN_USERNAME: &str = "admin"; const DEFAULT_ADMIN_PASSWORD: &str = "admin123"; const DEFAULT_SESSION_COOKIE_NAME: &str = "termi_admin_session"; const DEFAULT_SESSION_TTL_SECONDS: i64 = 43_200; #[derive(Clone, Debug, Serialize)] pub struct AdminIdentity { pub username: String, pub email: Option, pub source: String, pub provider: Option, pub groups: Vec, } #[derive(Clone, Debug)] struct LocalSessionRecord { username: String, email: Option, expires_at: chrono::DateTime, } static ADMIN_SESSIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); fn env_flag(name: &str, default: bool) -> bool { std::env::var(name) .ok() .map(|value| match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => true, "0" | "false" | "no" | "off" => false, _ => default, }) .unwrap_or(default) } fn session_cookie_name() -> String { std::env::var("TERMI_ADMIN_SESSION_COOKIE") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| DEFAULT_SESSION_COOKIE_NAME.to_string()) } fn proxy_shared_secret() -> Option { std::env::var("TERMI_ADMIN_PROXY_SHARED_SECRET") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn session_ttl_seconds() -> i64 { std::env::var("TERMI_ADMIN_SESSION_TTL_SECONDS") .ok() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(DEFAULT_SESSION_TTL_SECONDS) } fn header_value(headers: &HeaderMap, key: &'static str) -> Option { headers .get(key) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) } fn split_groups(value: Option) -> Vec { value .unwrap_or_default() .split([',', ';', ' ']) .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect() } fn cookie_value(headers: &HeaderMap, name: &str) -> Option { headers .get(header::COOKIE) .and_then(|value| value.to_str().ok()) .and_then(|raw| { raw.split(';').find_map(|part| { let (key, value) = part.trim().split_once('=')?; (key == name).then(|| value.trim().to_string()) }) }) } fn resolve_proxy_identity(headers: &HeaderMap) -> Option { if !proxy_auth_enabled() { return None; } if let Some(expected_secret) = proxy_shared_secret() { let provided_secret = header_value(headers, "X-Termi-Proxy-Secret")?; if provided_secret != expected_secret { tracing::warn!("proxy auth secret mismatch, ignoring forwarded admin identity headers"); return None; } } let username = [ header_value(headers, "Remote-User"), header_value(headers, "X-Forwarded-User"), header_value(headers, "X-Auth-Request-User"), ] .into_iter() .flatten() .find(|value| !value.is_empty())?; let email = [ header_value(headers, "Remote-Email"), header_value(headers, "X-Forwarded-Email"), header_value(headers, "X-Auth-Request-Email"), ] .into_iter() .flatten() .find(|value| !value.is_empty()); let provider = if header_value(headers, "Remote-User").is_some() { Some("TinyAuth".to_string()) } else { Some("Proxy SSO".to_string()) }; Some(AdminIdentity { username, email, source: "proxy".to_string(), provider, groups: split_groups( header_value(headers, "Remote-Groups") .or_else(|| header_value(headers, "X-Forwarded-Groups")), ), }) } fn resolve_local_identity(headers: &HeaderMap) -> Option { let token = cookie_value(headers, &session_cookie_name())?; let now = chrono::Utc::now(); let mut guard = ADMIN_SESSIONS.lock().ok()?; let session = match guard.get(&token) { Some(session) if session.expires_at > now => session.clone(), Some(_) => { guard.remove(&token); return None; } None => return None, }; Some(AdminIdentity { username: session.username, email: session.email, source: "local".to_string(), provider: Some("Built-in admin session".to_string()), groups: Vec::new(), }) } pub(crate) fn admin_username() -> String { std::env::var("TERMI_ADMIN_USERNAME").unwrap_or_else(|_| DEFAULT_ADMIN_USERNAME.to_string()) } pub(crate) fn admin_password() -> String { std::env::var("TERMI_ADMIN_PASSWORD").unwrap_or_else(|_| DEFAULT_ADMIN_PASSWORD.to_string()) } pub(crate) fn proxy_auth_enabled() -> bool { env_flag("TERMI_ADMIN_TRUST_PROXY_AUTH", false) } pub(crate) fn local_login_enabled() -> bool { env_flag("TERMI_ADMIN_LOCAL_LOGIN_ENABLED", true) } pub(crate) fn validate_admin_credentials(username: &str, password: &str) -> bool { username == admin_username() && password == admin_password() } pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option { resolve_proxy_identity(headers).or_else(|| resolve_local_identity(headers)) } pub(crate) fn check_auth(headers: &HeaderMap) -> Result { resolve_admin_identity(headers).ok_or_else(|| Error::Unauthorized("Not logged in".to_string())) } pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) { let token = Uuid::new_v4().to_string(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(session_ttl_seconds()); let record = LocalSessionRecord { username: username.to_string(), email: None, expires_at, }; if let Ok(mut sessions) = ADMIN_SESSIONS.lock() { sessions.insert(token.clone(), record); } let cookie = format!( "{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}", session_cookie_name(), token, session_ttl_seconds() ); ( AdminIdentity { username: username.to_string(), email: None, source: "local".to_string(), provider: Some("Built-in admin session".to_string()), groups: Vec::new(), }, token, cookie, ) } pub(crate) fn clear_local_session(headers: &HeaderMap) { let Some(token) = cookie_value(headers, &session_cookie_name()) else { return; }; if let Ok(mut sessions) = ADMIN_SESSIONS.lock() { sessions.remove(&token); } } pub(crate) fn clear_local_session_cookie() -> String { format!( "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", session_cookie_name() ) }