use std::collections::HashMap;
use axum::extract::Query;
use minijinja::context;
use umbral::web::{HeaderMap, IntoResponse, Redirect, Response, StatusCode};
use crate::engine::render;
use crate::util::urlencoding_simple;
pub(crate) async fn require_staff(
headers: &HeaderMap,
current_path: &str,
) -> Result<umbral_auth::AuthUser, Response> {
let next = sanitise_next(current_path);
let login_redirect = || {
let base = crate::branding::current().base_path;
let location = format!("{base}/login?next={}", urlencoding_simple(&next));
Redirect::to(&location).into_response()
};
let user = match umbral_auth::current_user(headers).await {
Ok(Some(u)) => u,
_ => return Err(login_redirect()),
};
if !user.is_staff {
return Err((StatusCode::FORBIDDEN, "umbral-admin: not a staff user").into_response());
}
Ok(user)
}
pub(crate) async fn login_get(
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
if let Ok(Some(user)) = umbral_auth::current_user(&headers).await {
if user.is_staff {
let next = params
.get("next")
.map(|n| sanitise_next(n))
.filter(|n| !n.is_empty())
.unwrap_or_else(|| format!("{}/", crate::branding::current().base_path));
return Redirect::to(&next).into_response();
}
}
let next = params
.get("next")
.map(|n| sanitise_next(n))
.unwrap_or_default();
let (csrf_token, new_cookie) = ensure_csrf_token(&headers);
let html = match render(
"admin/login.html",
context!(
csrf_token => csrf_token,
next => next,
error => "",
prefill_username => "",
),
) {
Ok(h) => h,
Err(e) => return e.into_response(),
};
if let Some(cookie_str) = new_cookie {
let mut resp = html.into_response();
if let Ok(value) = cookie_str.parse::<axum::http::HeaderValue>() {
resp.headers_mut()
.insert(axum::http::header::SET_COOKIE, value);
}
resp
} else {
html.into_response()
}
}
pub(crate) async fn login_post(headers: HeaderMap, body: String) -> Response {
let form: HashMap<String, String> = match serde_urlencoded::from_str(&body) {
Ok(m) => m,
Err(_) => return bad_login_response("Invalid form submission.", "", ""),
};
let username = form.get("username").map(|s| s.as_str()).unwrap_or("");
let password = form.get("password").map(|s| s.as_str()).unwrap_or("");
let next_raw = form.get("next").map(|s| s.as_str()).unwrap_or("");
let next = sanitise_next(next_raw);
let submitted_csrf = form.get("csrf_token").map(|s| s.as_str()).unwrap_or("");
let cookie_csrf = umbral_security::current_csrf_token(&headers).unwrap_or_default();
let csrf_ok = !submitted_csrf.is_empty()
&& !cookie_csrf.is_empty()
&& umbral_security::tokens_match(submitted_csrf, &cookie_csrf);
if !csrf_ok {
return bad_login_response_with_csrf(
"Your session expired. Please try again.",
username,
&next,
&cookie_csrf,
);
}
let user = match umbral_auth::authenticate::<umbral_auth::AuthUser>(username, password).await {
Ok(u) => u,
Err(_) => {
return bad_login_response_with_csrf(
"The username or password you entered is incorrect.",
username,
&next,
&cookie_csrf,
);
}
};
if !user.is_staff {
return bad_login_response_with_csrf(
"This account does not have admin access.",
username,
&next,
&cookie_csrf,
);
}
let redirect_to = if next.is_empty() {
format!("{}/", crate::branding::current().base_path)
} else {
next.clone()
};
let mut response = Redirect::to(&redirect_to).into_response();
if let Err(e) = umbral_auth::login_with_request(&headers, response.headers_mut(), &user).await {
tracing::error!(error = %e, "admin: login: session creation failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "session error").into_response();
}
response
}
fn ensure_csrf_token(headers: &HeaderMap) -> (String, Option<String>) {
if let Some(tok) = umbral::templates::current_csrf() {
return (tok, None);
}
if let Some(tok) = umbral_security::current_csrf_token(headers) {
return (tok, None);
}
let tok = umbral_security::generate_token();
let is_prod = matches!(
umbral::settings::get_opt().map(|s| &s.environment),
Some(umbral::Environment::Prod)
);
let mut cookie = format!("umbral_csrf_token={tok}; Path=/; SameSite=Lax");
if is_prod {
cookie.push_str("; Secure");
}
(tok, Some(cookie))
}
fn bad_login_response(error: &str, prefill_username: &str, next: &str) -> Response {
bad_login_response_with_csrf(error, prefill_username, next, "")
}
fn bad_login_response_with_csrf(
error: &str,
prefill_username: &str,
next: &str,
csrf_token: &str,
) -> Response {
match render(
"admin/login.html",
context!(
csrf_token => csrf_token,
next => next,
error => error,
prefill_username => prefill_username,
),
) {
Ok(html) => (StatusCode::UNPROCESSABLE_ENTITY, html).into_response(),
Err(e) => e.into_response(),
}
}
pub(crate) async fn logout_handler(headers: HeaderMap) -> Response {
let base = crate::branding::current().base_path;
let mut response = Redirect::to(&format!("{base}/login")).into_response();
let _ = umbral_sessions::logout(&headers, response.headers_mut()).await;
response
}
pub(crate) fn sanitise_next(raw: &str) -> String {
let base = crate::branding::current().base_path;
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("//") || trimmed.contains("://") {
return format!("{base}/");
}
if !trimmed.starts_with(&*base) {
return format!("{base}/");
}
trimmed.to_string()
}
#[cfg(test)]
mod tests {
use super::sanitise_next;
#[test]
fn rejects_external_urls() {
assert_eq!(sanitise_next("http://evil.com/"), "/admin/");
assert_eq!(sanitise_next("https://evil.com/"), "/admin/");
assert_eq!(sanitise_next("//evil.com/"), "/admin/");
}
#[test]
fn rejects_non_admin_paths() {
assert_eq!(sanitise_next("/app/dashboard"), "/admin/");
assert_eq!(sanitise_next("/login"), "/admin/");
}
#[test]
fn accepts_admin_paths() {
assert_eq!(sanitise_next("/admin/"), "/admin/");
assert_eq!(sanitise_next("/admin/note/"), "/admin/note/");
assert_eq!(sanitise_next("/admin"), "/admin");
}
#[test]
fn empty_stays_empty() {
assert_eq!(sanitise_next(""), "");
assert_eq!(sanitise_next(" "), "");
}
}
#[cfg(test)]
mod csrf_tests {
use super::*;
#[tokio::test]
async fn ensure_csrf_token_prefers_the_ambient_token() {
let headers = HeaderMap::new();
let (tok, cookie) =
umbral::templates::with_current_csrf(Some("ambient-token".to_string()), async {
ensure_csrf_token(&headers)
})
.await;
assert_eq!(tok, "ambient-token");
assert!(
cookie.is_none(),
"middleware owns the cookie; admin must not set one"
);
}
#[tokio::test]
async fn ensure_csrf_token_self_mints_without_middleware() {
let headers = HeaderMap::new();
let (tok, cookie) = ensure_csrf_token(&headers);
assert!(!tok.is_empty());
assert!(
cookie.is_some(),
"no middleware, no cookie: admin must self-mint"
);
}
}