Hostityourself/server/src/auth.rs
Claude 1671aaf8e8
fix: break infinite redirect for non-admin users on admin UI
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
2026-03-23 08:24:41 +00:00

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>"#
)
}