249 lines
7.1 KiB
Rust
249 lines
7.1 KiB
Rust
use axum::http::{header, HeaderMap};
|
|
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<String>,
|
|
pub source: String,
|
|
pub provider: Option<String>,
|
|
pub groups: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct LocalSessionRecord {
|
|
username: String,
|
|
email: Option<String>,
|
|
expires_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
static ADMIN_SESSIONS: LazyLock<Mutex<HashMap<String, LocalSessionRecord>>> =
|
|
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<String> {
|
|
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::<i64>().ok())
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(DEFAULT_SESSION_TTL_SECONDS)
|
|
}
|
|
|
|
fn header_value(headers: &HeaderMap, key: &'static str) -> Option<String> {
|
|
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<String>) -> Vec<String> {
|
|
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<String> {
|
|
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<AdminIdentity> {
|
|
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<AdminIdentity> {
|
|
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<AdminIdentity> {
|
|
resolve_proxy_identity(headers).or_else(|| resolve_local_identity(headers))
|
|
}
|
|
|
|
pub(crate) fn check_auth(headers: &HeaderMap) -> Result<AdminIdentity> {
|
|
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()
|
|
)
|
|
}
|