From 2a84861284423312d446badbd531ed78e8f2d5e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 03:18:38 +0000 Subject: [PATCH 1/2] Add browser-friendly auth with login page and session cookies When CHOMP_AUTH_KEY is set, browser requests to protected routes are redirected to a login page instead of receiving a raw 401. On successful login, a session cookie is set so the dashboard and API calls work seamlessly from the browser. API clients can still use Bearer tokens. - Add login.html with password form matching dashboard theme - Add GET/POST /login and POST /logout as public routes - Update auth middleware to accept chomp_session cookie - Redirect browser requests (Accept: text/html) to /login when unauthenticated - Add sign-out button to dashboard header - Add urlencoding dependency for safe redirect URL encoding https://claude.ai/code/session_013RUKGWx6PH3fXQ6pRyKcVs --- Cargo.lock | 7 +++ Cargo.toml | 1 + dashboard.html | 11 ++++- login.html | 55 +++++++++++++++++++++ src/sse.rs | 130 +++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 login.html diff --git a/Cargo.lock b/Cargo.lock index a9133d2..e3f91ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "tokio", "tokio-stream", "tower-http 0.5.2", + "urlencoding", "uuid", "zip", ] @@ -2128,6 +2129,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index dc866fe..9f702e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ tokio-stream = { version = "0.1", optional = true } uuid = { version = "1", features = ["v4"], optional = true } tower-http = { version = "0.5", features = ["cors"], optional = true } +urlencoding = "2" csv = "1" reqwest = { version = "0.12", features = ["blocking", "json"] } zip = "2" diff --git a/dashboard.html b/dashboard.html index 2c70bed..345bb9c 100644 --- a/dashboard.html +++ b/dashboard.html @@ -19,8 +19,9 @@ .header-left { flex: 1; } .subtitle { color: var(--muted); font-size: 0.9rem; } .controls { display: flex; gap: 8px; align-items: center; } - .controls select { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 0.85rem; cursor: pointer; } - .controls select:focus { outline: none; border-color: var(--accent); } + .controls select, .controls button { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 0.85rem; cursor: pointer; } + .controls select:focus, .controls button:focus { outline: none; border-color: var(--accent); } + .controls button:hover { background: var(--border); } .grid { display: grid; gap: 16px; margin-bottom: 24px; } .grid-4 { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .grid-2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } @@ -70,6 +71,7 @@

Chomp Dashboard

+ @@ -308,6 +310,11 @@

Chomp Dashboard

} } +async function logout() { + await fetch('/logout', { method: 'POST' }); + window.location.href = '/login'; +} + document.getElementById('dayRange').addEventListener('change', () => { loadDashboard().catch(err => { document.getElementById('summary-cards').innerHTML = `

Error

Failed to load
${err.message}
`; diff --git a/login.html b/login.html new file mode 100644 index 0000000..c659e0c --- /dev/null +++ b/login.html @@ -0,0 +1,55 @@ + + + + + +Chomp — Login + + + +
+

Chomp

+

Enter your auth key to access the dashboard.

+
+ + + +

Invalid auth key. Please try again.

+
+
+ + + diff --git a/src/sse.rs b/src/sse.rs index f9d7144..bc1c4a8 100644 --- a/src/sse.rs +++ b/src/sse.rs @@ -5,7 +5,7 @@ use axum::{ middleware::{self, Next}, response::{ sse::{Event, KeepAlive}, - Html, IntoResponse, Response, Sse, + Html, IntoResponse, Redirect, Response, Sse, }, routing::{delete, get, post, put}, Json, Router, @@ -83,6 +83,8 @@ pub async fn serve_sse(port: u16, host: &str, auth_key: Option<&str>) -> Result< )) // Public routes (after route_layer) .route("/health", get(health_handler)) + .route("/login", get(login_page_handler).post(login_handler)) + .route("/logout", post(logout_handler)) .layer(cors) .with_state(state); @@ -104,34 +106,64 @@ pub async fn serve_sse(port: u16, host: &str, auth_key: Option<&str>) -> Result< Ok(()) } -/// Middleware that checks for a valid Bearer token when auth is enabled. +/// Extract the chomp_session cookie value from a request. +fn get_session_cookie(request: &Request) -> Option { + request + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix("chomp_session=").map(String::from) + }) + }) +} + +/// Returns true if the request looks like it came from a browser expecting HTML. +fn is_browser_request(request: &Request) -> bool { + request + .headers() + .get("accept") + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("text/html")) + .unwrap_or(false) +} + +/// Middleware that checks for a valid Bearer token or session cookie when auth is enabled. async fn auth_middleware( State(state): State>, request: Request, next: Next, ) -> Response { if let Some(expected_key) = &state.auth_key { - let auth_header = request + // Check Bearer token first + let bearer_ok = request .headers() .get("authorization") - .and_then(|v| v.to_str().ok()); - - match auth_header { - Some(header) if header.starts_with("Bearer ") => { - let token = &header[7..]; - if token != expected_key.as_str() { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("Invalid auth key".into()) - .unwrap(); - } - } - _ => { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("Missing Authorization: Bearer header".into()) - .unwrap(); + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(|token| token == expected_key.as_str()) + .unwrap_or(false); + + // Then check session cookie + let cookie_ok = get_session_cookie(&request) + .map(|token| token == *expected_key) + .unwrap_or(false); + + if !bearer_ok && !cookie_ok { + // Redirect browsers to login page; return 401 for API clients + if is_browser_request(&request) { + let path = request.uri().path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/dashboard"); + let login_url = format!("/login?next={}", urlencoding::encode(path)); + return Redirect::to(&login_url).into_response(); } + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Missing or invalid Authorization: Bearer header".into()) + .unwrap(); } } @@ -548,6 +580,64 @@ async fn stats_handler() -> impl IntoResponse { } } +/// GET /login — serves the login page. +async fn login_page_handler(State(state): State>) -> Response { + if state.auth_key.is_none() { + // No auth configured — redirect straight to dashboard + return Redirect::to("/dashboard").into_response(); + } + let html = include_str!("../login.html"); + Html(html).into_response() +} + +#[derive(Deserialize)] +struct LoginRequest { + key: String, +} + +/// POST /login — validates the auth key and sets a session cookie. +async fn login_handler( + State(state): State>, + Json(body): Json, +) -> Response { + let expected = match &state.auth_key { + Some(k) => k, + None => { + // No auth configured — just succeed + return StatusCode::OK.into_response(); + } + }; + + if body.key != *expected { + return StatusCode::UNAUTHORIZED.into_response(); + } + + Response::builder() + .status(StatusCode::OK) + .header( + "set-cookie", + format!( + "chomp_session={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + body.key, + 60 * 60 * 24 * 30 // 30 days + ), + ) + .body("OK".into()) + .unwrap() +} + +/// POST /logout — clears the session cookie. +async fn logout_handler() -> Response { + Response::builder() + .status(StatusCode::OK) + .header( + "set-cookie", + "chomp_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + ) + .body("OK".into()) + .unwrap() +} + /// GET /health — simple health check. async fn health_handler() -> Json { Json(serde_json::json!({ From b97530c1a96dd06d21ca25d369be041f811dad99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 09:50:11 +0000 Subject: [PATCH 2/2] Fix blank login page: add login.html to Docker build The Dockerfile was only copying dashboard.html but not login.html, so include_str!("../login.html") compiled to empty content in the Docker build. https://claude.ai/code/session_013RUKGWx6PH3fXQ6pRyKcVs --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 356bff7..27a7fd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY src/ src/ -COPY dashboard.html ./ +COPY dashboard.html login.html ./ RUN cargo build --release --features sse # Runtime stage