Hostityourself/server/src/auth.rs
Claude 4454744cba
Add session-based auth to dashboard and API
- New HIY_ADMIN_USER / HIY_ADMIN_PASS env vars control access
- Login page at /login with redirect-after-login support
- Cookie-based sessions (HttpOnly, SameSite=Strict); cleared on restart
- Auth middleware applied to all routes except /webhook/:app_id (HMAC) and /login
- Auth is skipped when credentials are not configured (dev mode, warns at startup)
- Logout link in both dashboard nav bars
- Caddy admin port 2019 no longer published to the host in docker-compose

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
2026-03-20 13:45:16 +00:00

169 lines
5.5 KiB
Rust

use axum::{
extract::{Form, Query, Request, State},
http::header,
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
};
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
pub type Sessions = Arc<Mutex<HashSet<String>>>;
pub fn new_sessions() -> Sessions {
Arc::new(Mutex::new(HashSet::new()))
}
// ── Middleware ──────────────────────────────────────────────────────────────
pub async fn auth_middleware(
State(state): State<crate::AppState>,
request: Request,
next: Next,
) -> Response {
// Auth disabled when credentials are not configured.
if state.admin_user.is_none() {
return next.run(request).await;
}
let token = session_cookie(request.headers());
let authenticated = match token {
Some(t) => state.sessions.lock().await.contains(&t),
None => false,
};
if authenticated {
next.run(request).await
} else {
let path = request
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
Redirect::to(&format!("/login?next={}", safe_path(path))).into_response()
}
}
fn session_cookie(headers: &axum::http::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())
})
})
}
/// Reject open-redirect: only allow relative paths.
fn safe_path(next: &str) -> String {
if next.starts_with('/') && !next.starts_with("//") {
next.to_string()
} else {
"/".to_string()
}
}
// ── Login / Logout ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct NextParam {
next: Option<String>,
}
#[derive(Deserialize)]
pub struct LoginForm {
username: String,
password: String,
next: Option<String>,
}
pub async fn login_page(Query(params): Query<NextParam>) -> impl IntoResponse {
let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into());
Html(login_html(&next, false))
}
pub async fn handle_login(
State(state): State<crate::AppState>,
Form(form): Form<LoginForm>,
) -> Response {
let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into());
let valid = state.admin_user.as_deref() == Some(form.username.as_str())
&& state.admin_pass.as_deref() == Some(form.password.as_str());
if !valid {
return Html(login_html(&next, true)).into_response();
}
let token = uuid::Uuid::new_v4().to_string();
state.sessions.lock().await.insert(token.clone());
let cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/", token);
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: axum::http::HeaderMap,
) -> Response {
if let Some(token) = session_cookie(&headers) {
state.sessions.lock().await.remove(&token);
}
let mut response = Redirect::to("/login").into_response();
response.headers_mut().insert(
header::SET_COOKIE,
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"
.parse()
.unwrap(),
);
response
}
// ── Login page HTML ─────────────────────────────────────────────────────────
fn login_html(next: &str, error: bool) -> String {
let error_html = if error {
r#"<p class="error">Invalid username or password.</p>"#
} else {
""
};
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>"#
)
}