Files
termi-blog/backend/src/controllers/admin.rs

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()
)
}