From c6dd1dffb0bd85c186efac19acbfe7264081401c Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 11:33:38 -0600 Subject: [PATCH] feat: add cookie-based session authentication system - Add admin user management with Argon2 password hashing - Implement session management with ULID-based tokens and 7-day expiry - Add authentication middleware for protected routes and API endpoints - Forward validated session to SvelteKit via trusted X-Session-User header - Refactor admin panel to use server-side authentication checks --- Cargo.lock | 69 +++++ Cargo.toml | 5 +- .../20260106030000_add_authentication.sql | 21 ++ src/auth.rs | 243 +++++++++++++++ src/main.rs | 284 +++++++++++++++++- src/middleware/auth.rs | 74 +++++ src/middleware/mod.rs | 5 + .../request_id.rs} | 0 src/tarpit.rs | 4 +- web/src/lib/stores/auth.svelte.ts | 131 ++++---- web/src/routes/admin/+layout.server.ts | 27 ++ web/src/routes/admin/+layout.svelte | 9 +- web/src/routes/admin/+layout.ts | 31 -- web/src/routes/admin/login/+page.svelte | 10 +- 14 files changed, 793 insertions(+), 120 deletions(-) create mode 100644 migrations/20260106030000_add_authentication.sql create mode 100644 src/auth.rs create mode 100644 src/middleware/auth.rs create mode 100644 src/middleware/mod.rs rename src/{middleware.rs => middleware/request_id.rs} (100%) create mode 100644 web/src/routes/admin/+layout.server.ts delete mode 100644 web/src/routes/admin/+layout.ts diff --git a/Cargo.lock b/Cargo.lock index 9229591..7a9880d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,9 +71,11 @@ dependencies = [ name = "api" version = "0.1.0" dependencies = [ + "argon2", "aws-config", "aws-sdk-s3", "axum", + "axum-extra", "clap", "dashmap", "dotenvy", @@ -94,9 +96,22 @@ dependencies = [ "tracing", "tracing-subscriber", "ulid", + "urlencoding", "uuid", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "atoi" version = "2.0.0" @@ -600,6 +615,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16ct" version = "0.1.1" @@ -637,6 +674,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -784,6 +830,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -935,6 +992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -2009,6 +2067,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 967d8cd..cdef5da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,11 @@ edition = "2024" default-run = "api" [dependencies] +argon2 = "0.5" aws-config = "1.8.12" aws-sdk-s3 = "1.119.0" axum = "0.8.8" +axum-extra = { version = "0.12", features = ["cookie"] } clap = { version = "4.5.54", features = ["derive", "env"] } dashmap = "6.1.0" dotenvy = "0.15" @@ -20,7 +22,7 @@ reqwest = { version = "0.13.1", default-features = false, features = ["rustls", serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "migrate"] } -time = { version = "0.3.44", features = ["formatting", "macros"] } +time = { version = "0.3.44", features = ["formatting", "macros", "serde"] } tokio = { version = "1.49.0", features = ["full"] } tokio-util = { version = "0.7.18", features = ["io"] } tower = "0.5" @@ -28,4 +30,5 @@ tower-http = { version = "0.6.8", features = ["trace", "cors", "limit"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } ulid = { version = "1", features = ["serde"] } +urlencoding = "2.1" uuid = { version = "1", features = ["serde", "v4"] } diff --git a/migrations/20260106030000_add_authentication.sql b/migrations/20260106030000_add_authentication.sql new file mode 100644 index 0000000..d0028b9 --- /dev/null +++ b/migrations/20260106030000_add_authentication.sql @@ -0,0 +1,21 @@ +-- Admin users table +CREATE TABLE admin_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Sessions table (ULID stored as text) +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for efficient queries +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..4842324 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,243 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use time::{Duration, OffsetDateTime}; +use ulid::Ulid; + +const SESSION_DURATION_DAYS: i64 = 7; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: Ulid, + pub user_id: i32, + pub username: String, + pub expires_at: OffsetDateTime, +} + +#[derive(Debug, Clone)] +pub struct AdminUser { + pub id: i32, + pub username: String, + pub password_hash: String, +} + +#[derive(Clone)] +pub struct SessionManager { + sessions: Arc>, + pool: PgPool, +} + +impl SessionManager { + pub async fn new(pool: PgPool) -> Result { + let manager = Self { + sessions: Arc::new(DashMap::new()), + pool, + }; + + manager.load_active_sessions().await?; + + Ok(manager) + } + + async fn load_active_sessions(&self) -> Result<(), sqlx::Error> { + let now = OffsetDateTime::now_utc(); + + let sessions: Vec<(String, i32, String, OffsetDateTime)> = sqlx::query_as( + r#" + SELECT s.id, s.user_id, u.username, s.expires_at + FROM sessions s + JOIN admin_users u ON s.user_id = u.id + WHERE s.expires_at > $1 + "#, + ) + .bind(now) + .fetch_all(&self.pool) + .await?; + + for (id_str, user_id, username, expires_at) in sessions { + if let Ok(id) = Ulid::from_string(&id_str) { + let session = Session { + id, + user_id, + username, + expires_at, + }; + self.sessions.insert(id, session); + } + } + + tracing::info!( + "Loaded {} active sessions from database", + self.sessions.len() + ); + + Ok(()) + } + + pub async fn create_session( + &self, + user_id: i32, + username: String, + ) -> Result { + let id = Ulid::new(); + let created_at = OffsetDateTime::now_utc(); + let expires_at = created_at + Duration::days(SESSION_DURATION_DAYS); + + sqlx::query( + r#" + INSERT INTO sessions (id, user_id, created_at, expires_at, last_active_at) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(id.to_string()) + .bind(user_id) + .bind(created_at) + .bind(expires_at) + .bind(created_at) + .execute(&self.pool) + .await?; + + let session = Session { + id, + user_id, + username, + expires_at, + }; + + self.sessions.insert(id, session.clone()); + + tracing::debug!("Created session {} for user {}", id, user_id); + + Ok(session) + } + + pub fn get_session(&self, session_id: Ulid) -> Option { + self.sessions.get(&session_id).map(|s| s.clone()) + } + + pub fn validate_session(&self, session_id: Ulid) -> Option { + let session = self.get_session(session_id)?; + + if session.expires_at < OffsetDateTime::now_utc() { + self.sessions.remove(&session_id); + return None; + } + + Some(session) + } + + pub async fn delete_session(&self, session_id: Ulid) -> Result<(), sqlx::Error> { + self.sessions.remove(&session_id); + + sqlx::query("DELETE FROM sessions WHERE id = $1") + .bind(session_id.to_string()) + .execute(&self.pool) + .await?; + + tracing::debug!("Deleted session {}", session_id); + + Ok(()) + } + + pub async fn cleanup_expired(&self) -> Result { + let now = OffsetDateTime::now_utc(); + + let result = sqlx::query("DELETE FROM sessions WHERE expires_at < $1") + .bind(now) + .execute(&self.pool) + .await?; + + let expired_count = result.rows_affected() as usize; + + self.sessions.retain(|_, session| session.expires_at >= now); + + if expired_count > 0 { + tracing::info!("Cleaned up {} expired sessions", expired_count); + } + + Ok(expired_count) + } +} + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(password.as_bytes(), &salt)?; + Ok(password_hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash)?; + let argon2 = Argon2::default(); + + match argon2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(e) => Err(e), + } +} + +pub async fn get_admin_user( + pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + let user: Option<(i32, String, String)> = sqlx::query_as( + r#" + SELECT id, username, password_hash + FROM admin_users + WHERE username = $1 + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + Ok(user.map(|(id, username, password_hash)| AdminUser { + id, + username, + password_hash, + })) +} + +pub async fn create_admin_user( + pool: &PgPool, + username: &str, + password: &str, +) -> Result> { + let password_hash = + hash_password(password).map_err(|e| format!("Failed to hash password: {}", e))?; + + let (id,): (i32,) = sqlx::query_as( + r#" + INSERT INTO admin_users (username, password_hash) + VALUES ($1, $2) + RETURNING id + "#, + ) + .bind(username) + .bind(password_hash) + .fetch_one(pool) + .await?; + + Ok(id) +} + +pub async fn ensure_admin_user(pool: &PgPool) -> Result<(), Box> { + let username = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); + let password = std::env::var("ADMIN_PASSWORD") + .map_err(|_| "ADMIN_PASSWORD environment variable must be set")?; + + if get_admin_user(pool, &username).await?.is_none() { + create_admin_user(pool, &username, &password).await?; + tracing::info!("Created admin user: {}", username); + } else { + tracing::debug!("Admin user '{}' already exists", username); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9b16521..3ca90f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, trace::TraceLaye use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; mod assets; +mod auth; mod config; mod db; mod formatter; @@ -94,6 +95,32 @@ async fn main() { tracing::info!("Migrations applied successfully"); + // Ensure admin user exists + auth::ensure_admin_user(&pool) + .await + .expect("Failed to ensure admin user exists"); + + // Initialize session manager + let session_manager = Arc::new( + auth::SessionManager::new(pool.clone()) + .await + .expect("Failed to initialize session manager"), + ); + + // Spawn background task to cleanup expired sessions + tokio::spawn({ + let session_manager = session_manager.clone(); + async move { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour + loop { + interval.tick().await; + if let Err(e) = session_manager.cleanup_expired().await { + tracing::error!(error = %e, "Failed to cleanup expired sessions"); + } + } + } + }); + if args.listen.is_empty() { eprintln!("Error: At least one --listen address is required"); std::process::exit(1); @@ -106,6 +133,7 @@ async fn main() { .tcp_keepalive(Some(Duration::from_secs(60))) .timeout(Duration::from_secs(5)) // Default timeout for SSR .connect_timeout(Duration::from_secs(3)) + .redirect(reqwest::redirect::Policy::none()) // Don't follow redirects - pass them through .build() .expect("Failed to create HTTP client"); @@ -118,6 +146,7 @@ async fn main() { .pool_idle_timeout(Duration::from_secs(600)) // 10 minutes .timeout(Duration::from_secs(5)) // Default timeout for SSR .connect_timeout(Duration::from_secs(3)) + .redirect(reqwest::redirect::Policy::none()) // Don't follow redirects - pass them through .unix_socket(path) .build() .expect("Failed to create Unix socket client"), @@ -162,6 +191,7 @@ async fn main() { health_checker, tarpit_state, pool: pool.clone(), + session_manager: session_manager.clone(), }); // Regenerate common OGP images on startup @@ -264,6 +294,7 @@ pub struct AppState { health_checker: Arc, tarpit_state: Arc, pool: sqlx::PgPool, + session_manager: Arc, } #[derive(Debug)] @@ -315,7 +346,13 @@ fn api_routes() -> Router> { "/health", axum::routing::get(health_handler).head(health_handler), ) + // Authentication endpoints (public) + .route("/login", axum::routing::post(api_login_handler)) + .route("/logout", axum::routing::post(api_logout_handler)) + .route("/session", axum::routing::get(api_session_handler)) + // Projects - GET is public, other methods require auth .route("/projects", axum::routing::get(projects_handler)) + // Project tags - authentication checked in handlers .route( "/projects/{id}/tags", axum::routing::get(get_project_tags_handler).post(add_project_tag_handler), @@ -324,6 +361,7 @@ fn api_routes() -> Router> { "/projects/{id}/tags/{tag_id}", axum::routing::delete(remove_project_tag_handler), ) + // Tags - authentication checked in handlers .route( "/tags", axum::routing::get(list_tags_handler).post(create_tag_handler), @@ -527,8 +565,12 @@ struct CreateTagRequest { async fn create_tag_handler( State(state): State>, + jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } if payload.name.trim().is_empty() { return ( StatusCode::BAD_REQUEST, @@ -620,8 +662,12 @@ struct UpdateTagRequest { async fn update_tag_handler( State(state): State>, axum::extract::Path(slug): axum::extract::Path, + jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } if payload.name.trim().is_empty() { return ( StatusCode::BAD_REQUEST, @@ -736,7 +782,13 @@ async fn get_related_tags_handler( } } -async fn recalculate_cooccurrence_handler(State(state): State>) -> impl IntoResponse { +async fn recalculate_cooccurrence_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } match db::recalculate_tag_cooccurrence(&state.pool).await { Ok(()) => ( StatusCode::OK, @@ -759,6 +811,183 @@ async fn recalculate_cooccurrence_handler(State(state): State>) -> } } +// Authentication API handlers + +fn check_session(state: &AppState, jar: &axum_extra::extract::CookieJar) -> Option { + let session_cookie = jar.get("admin_session")?; + let session_id = ulid::Ulid::from_string(session_cookie.value()).ok()?; + state.session_manager.validate_session(session_id) +} + +fn require_auth_response() -> impl IntoResponse { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Unauthorized", + "message": "Authentication required" + })), + ) +} + +#[derive(serde::Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(serde::Serialize)] +struct LoginResponse { + success: bool, + username: String, +} + +#[derive(serde::Serialize)] +struct SessionResponse { + authenticated: bool, + username: String, +} + +async fn api_login_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, + Json(payload): Json, +) -> Result<(axum_extra::extract::CookieJar, Json), impl IntoResponse> { + let user = match auth::get_admin_user(&state.pool, &payload.username).await { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Invalid credentials", + "message": "Username or password incorrect" + })), + )); + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch admin user"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to authenticate" + })), + )); + } + }; + + let password_valid = match auth::verify_password(&payload.password, &user.password_hash) { + Ok(valid) => valid, + Err(err) => { + tracing::error!(error = %err, "Failed to verify password"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to authenticate" + })), + )); + } + }; + + if !password_valid { + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Invalid credentials", + "message": "Username or password incorrect" + })), + )); + } + + let session = match state + .session_manager + .create_session(user.id, user.username.clone()) + .await + { + Ok(session) => session, + Err(err) => { + tracing::error!(error = %err, "Failed to create session"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to create session" + })), + )); + } + }; + + let cookie = + axum_extra::extract::cookie::Cookie::build(("admin_session", session.id.to_string())) + .path("/") + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Lax) + .max_age(time::Duration::days(7)) + .build(); + + let jar = jar.add(cookie); + + tracing::info!(username = %user.username, "User logged in"); + + Ok(( + jar, + Json(LoginResponse { + success: true, + username: user.username, + }), + )) +} + +async fn api_logout_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, +) -> (axum_extra::extract::CookieJar, StatusCode) { + if let Some(cookie) = jar.get("admin_session") { + if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) { + if let Err(e) = state.session_manager.delete_session(session_id).await { + tracing::error!(error = %e, "Failed to delete session during logout"); + } + } + } + + let cookie = axum_extra::extract::cookie::Cookie::build(("admin_session", "")) + .path("/") + .max_age(time::Duration::ZERO) + .build(); + + (jar.add(cookie), StatusCode::OK) +} + +async fn api_session_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + let session_cookie = jar.get("admin_session"); + + let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok()); + + let session = session_id.and_then(|id| state.session_manager.validate_session(id)); + + match session { + Some(session) => ( + StatusCode::OK, + Json(SessionResponse { + authenticated: true, + username: session.username, + }), + ) + .into_response(), + None => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Unauthorized", + "message": "No valid session" + })), + ) + .into_response(), + } +} + // Project-Tag association handlers async fn get_project_tags_handler( @@ -806,8 +1035,12 @@ struct AddProjectTagRequest { async fn add_project_tag_handler( State(state): State>, axum::extract::Path(id): axum::extract::Path, + jar: axum_extra::extract::CookieJar, Json(payload): Json, ) -> impl IntoResponse { + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } let project_id = match uuid::Uuid::parse_str(&id) { Ok(id) => id, Err(_) => { @@ -869,7 +1102,11 @@ async fn add_project_tag_handler( async fn remove_project_tag_handler( State(state): State>, axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>, + jar: axum_extra::extract::CookieJar, ) -> impl IntoResponse { + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } let project_id = match uuid::Uuid::parse_str(&id) { Ok(id) => id, Err(_) => { @@ -1011,9 +1248,43 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon format!("{}{}?{}", state.downstream_url, path, query) }; + // Build trusted headers to forward to downstream + let mut forward_headers = HeaderMap::new(); + + // SECURITY: Strip any X-Session-User header from incoming request to prevent spoofing + // (We will add it ourselves if session is valid) + + // Extract and validate session from cookie + if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + // Parse cookies manually to find admin_session + for cookie_pair in cookie_str.split(';') { + let cookie_pair = cookie_pair.trim(); + if let Some((name, value)) = cookie_pair.split_once('=') { + if name == "admin_session" { + // Found session cookie, validate it + if let Ok(session_id) = ulid::Ulid::from_string(value) { + if let Some(session) = + state.session_manager.validate_session(session_id) + { + // Session is valid - add trusted header + if let Ok(username_value) = + axum::http::HeaderValue::from_str(&session.username) + { + forward_headers.insert("x-session-user", username_value); + } + } + } + break; + } + } + } + } + } + let start = std::time::Instant::now(); - match proxy_to_bun(&bun_url, state.clone()).await { + match proxy_to_bun(&bun_url, state.clone(), forward_headers).await { Ok((status, headers, body)) => { let duration_ms = start.elapsed().as_millis() as u64; let cache = "miss"; @@ -1096,6 +1367,7 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon async fn proxy_to_bun( url: &str, state: Arc, + forward_headers: HeaderMap, ) -> Result<(StatusCode, HeaderMap, axum::body::Bytes), ProxyError> { let client = if state.unix_client.is_some() { state.unix_client.as_ref().unwrap() @@ -1103,7 +1375,13 @@ async fn proxy_to_bun( &state.http_client }; - let response = client.get(url).send().await.map_err(ProxyError::Network)?; + // Build request with forwarded headers + let mut request_builder = client.get(url); + for (name, value) in forward_headers.iter() { + request_builder = request_builder.header(name, value); + } + + let response = request_builder.send().await.map_err(ProxyError::Network)?; let status = StatusCode::from_u16(response.status().as_u16()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..bbe0e18 --- /dev/null +++ b/src/middleware/auth.rs @@ -0,0 +1,74 @@ +use crate::auth::{Session, SessionManager}; +use axum::{ + Json, + body::Body, + extract::{Request, State}, + http::{StatusCode, Uri}, + middleware::Next, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::CookieJar; +use serde_json::json; +use std::sync::Arc; + +const SESSION_COOKIE_NAME: &str = "admin_session"; + +pub async fn require_admin_auth( + State(session_mgr): State>, + jar: CookieJar, + uri: Uri, + mut req: Request, + next: Next, +) -> Result { + let session_cookie = jar.get(SESSION_COOKIE_NAME); + + let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok()); + + let session = session_id.and_then(|id| session_mgr.validate_session(id)); + + match session { + Some(session) => { + req.extensions_mut().insert(session); + Ok(next.run(req).await) + } + None => { + let next_param = urlencoding::encode(uri.path()); + let redirect_url = format!("/admin/login?next={}", next_param); + Err(Redirect::to(&redirect_url).into_response()) + } + } +} + +pub async fn require_api_auth( + State(session_mgr): State>, + jar: CookieJar, + mut req: Request, + next: Next, +) -> Result { + let session_cookie = jar.get(SESSION_COOKIE_NAME); + + let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok()); + + let session = session_id.and_then(|id| session_mgr.validate_session(id)); + + match session { + Some(session) => { + req.extensions_mut().insert(session); + Ok(next.run(req).await) + } + None => { + let error_response = ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "Unauthorized", + "message": "Authentication required" + })), + ); + Err(error_response.into_response()) + } + } +} + +pub fn extract_session(req: &Request) -> Option { + req.extensions().get::().cloned() +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..5d68186 --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod request_id; + +pub use auth::{require_admin_auth, require_api_auth}; +pub use request_id::RequestIdLayer; diff --git a/src/middleware.rs b/src/middleware/request_id.rs similarity index 100% rename from src/middleware.rs rename to src/middleware/request_id.rs diff --git a/src/tarpit.rs b/src/tarpit.rs index 2c7c9d5..20c7443 100644 --- a/src/tarpit.rs +++ b/src/tarpit.rs @@ -125,9 +125,7 @@ pub fn is_malicious_path(path: &str) -> bool { } // Admin panels - if path_lower.starts_with("/administrator") - || path_lower.contains("phpmyadmin") - { + if path_lower.starts_with("/administrator") || path_lower.contains("phpmyadmin") { return true; } diff --git a/web/src/lib/stores/auth.svelte.ts b/web/src/lib/stores/auth.svelte.ts index fe779b4..03085c9 100644 --- a/web/src/lib/stores/auth.svelte.ts +++ b/web/src/lib/stores/auth.svelte.ts @@ -1,89 +1,70 @@ -// Mock admin authentication store -// TODO: Replace with real backend authentication when ready - -import type { AuthSession } from "$lib/admin-types"; - -const SESSION_KEY = "admin_session"; -const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - -// Mock credentials (replace with backend auth) -const MOCK_USERNAME = "admin"; -const MOCK_PASSWORD = "password"; - class AuthStore { - private session = $state(null); - private initialized = $state(false); - - constructor() { - // Initialize from localStorage when the store is created - if (typeof window !== "undefined") { - this.loadSession(); - } - } - - get isAuthenticated(): boolean { - if (!this.session) return false; - - const expiresAt = new Date(this.session.expiresAt); - const now = new Date(); - - if (now > expiresAt) { - this.logout(); - return false; - } - - return true; - } - - get isInitialized(): boolean { - return this.initialized; - } - - private loadSession() { - try { - const stored = localStorage.getItem(SESSION_KEY); - if (stored) { - const session = JSON.parse(stored) as AuthSession; - this.session = session; - } - } catch (error) { - console.error("Failed to load session:", error); - } finally { - this.initialized = true; - } - } - - private saveSession() { - if (this.session) { - localStorage.setItem(SESSION_KEY, JSON.stringify(this.session)); - } else { - localStorage.removeItem(SESSION_KEY); - } - } + isAuthenticated = $state(false); + username = $state(null); async login(username: string, password: string): Promise { - // TODO: Replace with real API call to /admin/api/login - await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + credentials: "include", + }); - if (username === MOCK_USERNAME && password === MOCK_PASSWORD) { - const now = new Date(); - const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS); + if (response.ok) { + const data = await response.json(); + this.isAuthenticated = true; + this.username = data.username; + return true; + } - this.session = { - token: `mock-token-${Date.now()}`, - expiresAt: expiresAt.toISOString(), - }; + return false; + } catch (error) { + console.error("Login error:", error); + return false; + } + } - this.saveSession(); - return true; + async logout(): Promise { + try { + await fetch("/api/logout", { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout error:", error); + } finally { + this.isAuthenticated = false; + this.username = null; + } + } + + async checkSession(): Promise { + try { + const response = await fetch("/api/session", { + credentials: "include", + }); + + if (response.ok) { + const session = await response.json(); + this.isAuthenticated = true; + this.username = session.username; + return true; + } + } catch (error) { + console.error("Session check error:", error); } + this.isAuthenticated = false; + this.username = null; return false; } - logout() { - this.session = null; - this.saveSession(); + setSession(username: string): void { + this.isAuthenticated = true; + this.username = username; } } diff --git a/web/src/routes/admin/+layout.server.ts b/web/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..85520c3 --- /dev/null +++ b/web/src/routes/admin/+layout.server.ts @@ -0,0 +1,27 @@ +import { redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; + +export const load: LayoutServerLoad = async ({ request, url }) => { + // Login page doesn't require authentication + if (url.pathname === "/admin/login") { + return {}; + } + + // Read trusted header from Rust proxy (cannot be spoofed by client) + const sessionUser = request.headers.get("x-session-user"); + + if (!sessionUser) { + // Not authenticated - redirect to login with next parameter + throw redirect( + 302, + `/admin/login?next=${encodeURIComponent(url.pathname + url.search)}` + ); + } + + return { + session: { + authenticated: true, + username: sessionUser, + }, + }; +}; diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 72b1e6b..dd45c04 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -7,7 +7,7 @@ import { getAdminStats } from "$lib/api"; import type { AdminStats } from "$lib/admin-types"; - let { children } = $props(); + let { children, data } = $props(); let stats = $state(null); let loading = $state(true); @@ -28,6 +28,13 @@ } } + // Sync authStore with server session on mount + $effect(() => { + if (data?.session?.authenticated && data.session.username && !authStore.isAuthenticated) { + authStore.setSession(data.session.username); + } + }); + // Load stats when component mounts or when authentication changes $effect(() => { if (authStore.isAuthenticated && !isLoginPage) { diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts deleted file mode 100644 index 6d8e604..0000000 --- a/web/src/routes/admin/+layout.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { browser } from "$app/environment"; -import { goto } from "$app/navigation"; -import { authStore } from "$lib/stores/auth.svelte"; - -export const ssr = false; // Admin is client-side only - -export async function load({ url }) { - if (browser) { - // Wait for auth store to initialize - while (!authStore.isInitialized) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - // Allow access to login page without authentication - if (url.pathname === "/admin/login") { - // If already authenticated, redirect to dashboard - if (authStore.isAuthenticated) { - goto("/admin"); - } - return {}; - } - - // Require authentication for all other admin pages - if (!authStore.isAuthenticated) { - goto("/admin/login"); - return {}; - } - } - - return {}; -} diff --git a/web/src/routes/admin/login/+page.svelte b/web/src/routes/admin/login/+page.svelte index bf145cc..e1e0a8b 100644 --- a/web/src/routes/admin/login/+page.svelte +++ b/web/src/routes/admin/login/+page.svelte @@ -1,5 +1,6 @@