Root cause: auth_middleware redirected all non-admins (including logged-in ones) to /login, and login_page redirected logged-in users back — a loop. Fix: - auth_middleware now distinguishes unauthenticated (→ /login?next=) from logged-in-but-not-admin (→ /denied), breaking the loop entirely - /denied page's "sign in with a different account" link now goes to /logout first, so clicking it clears the session before the login form appears The login_page auto-redirect for logged-in users is restored, which is required for the Caddy forward_auth flow (deployed apps redirecting through /login?next=<app-url>). https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
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<Mutex<HashMap<String, String>>>;
|
|
|
|
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<String> {
|
|
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<String> {
|
|
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<crate::AppState>,
|
|
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<crate::AppState>,
|
|
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<i64> =
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginForm {
|
|
username: String,
|
|
password: String,
|
|
next: Option<String>,
|
|
}
|
|
|
|
pub async fn login_page(
|
|
State(state): State<crate::AppState>,
|
|
headers: HeaderMap,
|
|
Query(params): Query<NextParam>,
|
|
) -> 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<crate::AppState>,
|
|
Form(form): Form<LoginForm>,
|
|
) -> 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<crate::models::User> =
|
|
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<crate::AppState>,
|
|
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<std::collections::HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
let app = params.get("app").map(|s| s.as_str()).unwrap_or("this app");
|
|
Html(format!(
|
|
r#"<!doctype html><html><head><meta charset=utf-8><title>Access denied</title><style>
|
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
body{{font-family:monospace;background:#0f172a;color:#e2e8f0;
|
|
display:flex;align-items:center;justify-content:center;min-height:100vh}}
|
|
.card{{background:#1e293b;border-radius:10px;padding:32px;max-width:400px;text-align:center}}
|
|
h1{{color:#f87171;margin-bottom:16px}}p{{color:#94a3b8;margin-bottom:20px}}
|
|
a{{color:#818cf8}}</style></head><body>
|
|
<div class="card">
|
|
<h1>Access denied</h1>
|
|
<p>You do not have access to <strong>{app}</strong>.</p>
|
|
<p>Contact your administrator to request access.</p>
|
|
<a href="/logout">← Sign in with a different account</a>
|
|
</div></body></html>"#
|
|
))
|
|
}
|
|
|
|
// ── Login page HTML ─────────────────────────────────────────────────────────
|
|
|
|
fn login_html(next: &str, error: Option<&str>) -> String {
|
|
let error_html = match error {
|
|
Some(msg) => format!(r#"<p class="error">{}</p>"#, msg),
|
|
None => String::new(),
|
|
};
|
|
format!(
|
|
r#"<!doctype html><html><head><meta charset=utf-8>
|
|
<title>HIY — Login</title><style>
|
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
body{{font-family:monospace;background:#0f172a;color:#e2e8f0;
|
|
display:flex;align-items:center;justify-content:center;min-height:100vh}}
|
|
h1{{color:#a78bfa;font-size:1.4rem;margin-bottom:20px;text-align:center}}
|
|
.card{{background:#1e293b;border-radius:10px;padding:32px;width:100%;max-width:360px}}
|
|
label{{display:block;color:#64748b;font-size:0.78rem;margin-bottom:4px;margin-top:14px}}
|
|
input[type=text],input[type=password]{{
|
|
background:#0f172a;color:#e2e8f0;border:1px solid #334155;
|
|
padding:6px 10px;border-radius:6px;font-family:monospace;font-size:0.9rem;width:100%}}
|
|
button[type=submit]{{
|
|
background:#4c1d95;border:1px solid #7c3aed;color:#ddd6fe;
|
|
padding:8px 0;border-radius:6px;cursor:pointer;font-family:monospace;
|
|
font-size:0.9rem;width:100%;margin-top:20px}}
|
|
button[type=submit]:hover{{background:#5b21b6}}
|
|
.error{{color:#f87171;font-size:0.85rem;margin-top:12px;text-align:center}}
|
|
</style></head><body>
|
|
<div class="card">
|
|
<h1>HostItYourself</h1>
|
|
<form method=post action="/login">
|
|
<input type=hidden name=next value="{next}">
|
|
<label>Username</label>
|
|
<input type=text name=username autofocus autocomplete=username>
|
|
<label>Password</label>
|
|
<input type=password name=password autocomplete=current-password>
|
|
<button type=submit>Sign in</button>
|
|
{error_html}
|
|
</form>
|
|
</div></body></html>"#
|
|
)
|
|
}
|