- 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
169 lines
5.5 KiB
Rust
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>"#
|
|
)
|
|
}
|