mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 02:26:38 -06:00
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
This commit is contained in:
Generated
+69
@@ -71,9 +71,11 @@ dependencies = [
|
|||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -94,9 +96,22 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ulid",
|
"ulid",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -600,6 +615,28 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "base16ct"
|
name = "base16ct"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -637,6 +674,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -784,6 +830,17 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -935,6 +992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2009,6 +2067,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|||||||
+4
-1
@@ -5,9 +5,11 @@ edition = "2024"
|
|||||||
default-run = "api"
|
default-run = "api"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
argon2 = "0.5"
|
||||||
aws-config = "1.8.12"
|
aws-config = "1.8.12"
|
||||||
aws-sdk-s3 = "1.119.0"
|
aws-sdk-s3 = "1.119.0"
|
||||||
axum = "0.8.8"
|
axum = "0.8.8"
|
||||||
|
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
dotenvy = "0.15"
|
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 = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.148"
|
serde_json = "1.0.148"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "migrate"] }
|
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 = { version = "1.49.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7.18", features = ["io"] }
|
tokio-util = { version = "0.7.18", features = ["io"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
@@ -28,4 +30,5 @@ tower-http = { version = "0.6.8", features = ["trace", "cors", "limit"] }
|
|||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||||
ulid = { version = "1", features = ["serde"] }
|
ulid = { version = "1", features = ["serde"] }
|
||||||
|
urlencoding = "2.1"
|
||||||
uuid = { version = "1", features = ["serde", "v4"] }
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
|
|||||||
@@ -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);
|
||||||
+243
@@ -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<DashMap<Ulid, Session>>,
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
pub async fn new(pool: PgPool) -> Result<Self, sqlx::Error> {
|
||||||
|
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<Session, sqlx::Error> {
|
||||||
|
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<Session> {
|
||||||
|
self.sessions.get(&session_id).map(|s| s.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_session(&self, session_id: Ulid) -> Option<Session> {
|
||||||
|
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<usize, sqlx::Error> {
|
||||||
|
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<String, argon2::password_hash::Error> {
|
||||||
|
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<bool, argon2::password_hash::Error> {
|
||||||
|
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<Option<AdminUser>, 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<i32, Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
+281
-3
@@ -14,6 +14,7 @@ use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, trace::TraceLaye
|
|||||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod assets;
|
mod assets;
|
||||||
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod formatter;
|
mod formatter;
|
||||||
@@ -94,6 +95,32 @@ async fn main() {
|
|||||||
|
|
||||||
tracing::info!("Migrations applied successfully");
|
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() {
|
if args.listen.is_empty() {
|
||||||
eprintln!("Error: At least one --listen address is required");
|
eprintln!("Error: At least one --listen address is required");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -106,6 +133,7 @@ async fn main() {
|
|||||||
.tcp_keepalive(Some(Duration::from_secs(60)))
|
.tcp_keepalive(Some(Duration::from_secs(60)))
|
||||||
.timeout(Duration::from_secs(5)) // Default timeout for SSR
|
.timeout(Duration::from_secs(5)) // Default timeout for SSR
|
||||||
.connect_timeout(Duration::from_secs(3))
|
.connect_timeout(Duration::from_secs(3))
|
||||||
|
.redirect(reqwest::redirect::Policy::none()) // Don't follow redirects - pass them through
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@@ -118,6 +146,7 @@ async fn main() {
|
|||||||
.pool_idle_timeout(Duration::from_secs(600)) // 10 minutes
|
.pool_idle_timeout(Duration::from_secs(600)) // 10 minutes
|
||||||
.timeout(Duration::from_secs(5)) // Default timeout for SSR
|
.timeout(Duration::from_secs(5)) // Default timeout for SSR
|
||||||
.connect_timeout(Duration::from_secs(3))
|
.connect_timeout(Duration::from_secs(3))
|
||||||
|
.redirect(reqwest::redirect::Policy::none()) // Don't follow redirects - pass them through
|
||||||
.unix_socket(path)
|
.unix_socket(path)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create Unix socket client"),
|
.expect("Failed to create Unix socket client"),
|
||||||
@@ -162,6 +191,7 @@ async fn main() {
|
|||||||
health_checker,
|
health_checker,
|
||||||
tarpit_state,
|
tarpit_state,
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
|
session_manager: session_manager.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regenerate common OGP images on startup
|
// Regenerate common OGP images on startup
|
||||||
@@ -264,6 +294,7 @@ pub struct AppState {
|
|||||||
health_checker: Arc<HealthChecker>,
|
health_checker: Arc<HealthChecker>,
|
||||||
tarpit_state: Arc<TarpitState>,
|
tarpit_state: Arc<TarpitState>,
|
||||||
pool: sqlx::PgPool,
|
pool: sqlx::PgPool,
|
||||||
|
session_manager: Arc<auth::SessionManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -315,7 +346,13 @@ fn api_routes() -> Router<Arc<AppState>> {
|
|||||||
"/health",
|
"/health",
|
||||||
axum::routing::get(health_handler).head(health_handler),
|
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))
|
.route("/projects", axum::routing::get(projects_handler))
|
||||||
|
// Project tags - authentication checked in handlers
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}/tags",
|
"/projects/{id}/tags",
|
||||||
axum::routing::get(get_project_tags_handler).post(add_project_tag_handler),
|
axum::routing::get(get_project_tags_handler).post(add_project_tag_handler),
|
||||||
@@ -324,6 +361,7 @@ fn api_routes() -> Router<Arc<AppState>> {
|
|||||||
"/projects/{id}/tags/{tag_id}",
|
"/projects/{id}/tags/{tag_id}",
|
||||||
axum::routing::delete(remove_project_tag_handler),
|
axum::routing::delete(remove_project_tag_handler),
|
||||||
)
|
)
|
||||||
|
// Tags - authentication checked in handlers
|
||||||
.route(
|
.route(
|
||||||
"/tags",
|
"/tags",
|
||||||
axum::routing::get(list_tags_handler).post(create_tag_handler),
|
axum::routing::get(list_tags_handler).post(create_tag_handler),
|
||||||
@@ -527,8 +565,12 @@ struct CreateTagRequest {
|
|||||||
|
|
||||||
async fn create_tag_handler(
|
async fn create_tag_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if check_session(&state, &jar).is_none() {
|
||||||
|
return require_auth_response().into_response();
|
||||||
|
}
|
||||||
if payload.name.trim().is_empty() {
|
if payload.name.trim().is_empty() {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -620,8 +662,12 @@ struct UpdateTagRequest {
|
|||||||
async fn update_tag_handler(
|
async fn update_tag_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::extract::Path(slug): axum::extract::Path<String>,
|
axum::extract::Path(slug): axum::extract::Path<String>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
Json(payload): Json<UpdateTagRequest>,
|
Json(payload): Json<UpdateTagRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if check_session(&state, &jar).is_none() {
|
||||||
|
return require_auth_response().into_response();
|
||||||
|
}
|
||||||
if payload.name.trim().is_empty() {
|
if payload.name.trim().is_empty() {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -736,7 +782,13 @@ async fn get_related_tags_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recalculate_cooccurrence_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn recalculate_cooccurrence_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
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 {
|
match db::recalculate_tag_cooccurrence(&state.pool).await {
|
||||||
Ok(()) => (
|
Ok(()) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -759,6 +811,183 @@ async fn recalculate_cooccurrence_handler(State(state): State<Arc<AppState>>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication API handlers
|
||||||
|
|
||||||
|
fn check_session(state: &AppState, jar: &axum_extra::extract::CookieJar) -> Option<auth::Session> {
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> Result<(axum_extra::extract::CookieJar, Json<LoginResponse>), 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<Arc<AppState>>,
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
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
|
// Project-Tag association handlers
|
||||||
|
|
||||||
async fn get_project_tags_handler(
|
async fn get_project_tags_handler(
|
||||||
@@ -806,8 +1035,12 @@ struct AddProjectTagRequest {
|
|||||||
async fn add_project_tag_handler(
|
async fn add_project_tag_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::extract::Path(id): axum::extract::Path<String>,
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
Json(payload): Json<AddProjectTagRequest>,
|
Json(payload): Json<AddProjectTagRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if check_session(&state, &jar).is_none() {
|
||||||
|
return require_auth_response().into_response();
|
||||||
|
}
|
||||||
let project_id = match uuid::Uuid::parse_str(&id) {
|
let project_id = match uuid::Uuid::parse_str(&id) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -869,7 +1102,11 @@ async fn add_project_tag_handler(
|
|||||||
async fn remove_project_tag_handler(
|
async fn remove_project_tag_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>,
|
axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
if check_session(&state, &jar).is_none() {
|
||||||
|
return require_auth_response().into_response();
|
||||||
|
}
|
||||||
let project_id = match uuid::Uuid::parse_str(&id) {
|
let project_id = match uuid::Uuid::parse_str(&id) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -1011,9 +1248,43 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
format!("{}{}?{}", state.downstream_url, path, query)
|
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();
|
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)) => {
|
Ok((status, headers, body)) => {
|
||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
let cache = "miss";
|
let cache = "miss";
|
||||||
@@ -1096,6 +1367,7 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
async fn proxy_to_bun(
|
async fn proxy_to_bun(
|
||||||
url: &str,
|
url: &str,
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
|
forward_headers: HeaderMap,
|
||||||
) -> Result<(StatusCode, HeaderMap, axum::body::Bytes), ProxyError> {
|
) -> Result<(StatusCode, HeaderMap, axum::body::Bytes), ProxyError> {
|
||||||
let client = if state.unix_client.is_some() {
|
let client = if state.unix_client.is_some() {
|
||||||
state.unix_client.as_ref().unwrap()
|
state.unix_client.as_ref().unwrap()
|
||||||
@@ -1103,7 +1375,13 @@ async fn proxy_to_bun(
|
|||||||
&state.http_client
|
&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())
|
let status = StatusCode::from_u16(response.status().as_u16())
|
||||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
|||||||
@@ -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<Arc<SessionManager>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
uri: Uri,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
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<Arc<SessionManager>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
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<Body>) -> Option<Session> {
|
||||||
|
req.extensions().get::<Session>().cloned()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+1
-3
@@ -125,9 +125,7 @@ pub fn is_malicious_path(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin panels
|
// Admin panels
|
||||||
if path_lower.starts_with("/administrator")
|
if path_lower.starts_with("/administrator") || path_lower.contains("phpmyadmin") {
|
||||||
|| path_lower.contains("phpmyadmin")
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
class AuthStore {
|
||||||
private session = $state<AuthSession | null>(null);
|
isAuthenticated = $state(false);
|
||||||
private initialized = $state(false);
|
username = $state<string | null>(null);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(username: string, password: string): Promise<boolean> {
|
async login(username: string, password: string): Promise<boolean> {
|
||||||
// TODO: Replace with real API call to /admin/api/login
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay
|
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) {
|
if (response.ok) {
|
||||||
const now = new Date();
|
const data = await response.json();
|
||||||
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS);
|
this.isAuthenticated = true;
|
||||||
|
this.username = data.username;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
this.session = {
|
return false;
|
||||||
token: `mock-token-${Date.now()}`,
|
} catch (error) {
|
||||||
expiresAt: expiresAt.toISOString(),
|
console.error("Login error:", error);
|
||||||
};
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.saveSession();
|
async logout(): Promise<void> {
|
||||||
return true;
|
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<boolean> {
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
setSession(username: string): void {
|
||||||
this.session = null;
|
this.isAuthenticated = true;
|
||||||
this.saveSession();
|
this.username = username;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import { getAdminStats } from "$lib/api";
|
import { getAdminStats } from "$lib/api";
|
||||||
import type { AdminStats } from "$lib/admin-types";
|
import type { AdminStats } from "$lib/admin-types";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
let stats = $state<AdminStats | null>(null);
|
let stats = $state<AdminStats | null>(null);
|
||||||
let loading = $state(true);
|
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
|
// Load stats when component mounts or when authentication changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (authStore.isAuthenticated && !isLoginPage) {
|
if (authStore.isAuthenticated && !isLoginPage) {
|
||||||
|
|||||||
@@ -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 {};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
import Button from "$lib/components/admin/Button.svelte";
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
import Input from "$lib/components/admin/Input.svelte";
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
const success = await authStore.login(username, password);
|
const success = await authStore.login(username, password);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
goto("/admin");
|
const nextUrl = $page.url.searchParams.get("next") || "/admin";
|
||||||
|
goto(nextUrl);
|
||||||
} else {
|
} else {
|
||||||
error = "Invalid username or password";
|
error = "Invalid username or password";
|
||||||
}
|
}
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
<title>Admin Login | xevion.dev</title>
|
<title>Admin Login | xevion.dev</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<AppWrapper bgColor="bg-admin-bg">
|
<AppWrapper>
|
||||||
<div class="flex min-h-screen items-center justify-center px-4">
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
<div class="w-full max-w-md space-y-4">
|
<div class="w-full max-w-md space-y-4">
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
@@ -70,10 +72,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="text-xs text-admin-text-muted">
|
|
||||||
Mock credentials: <code class="rounded bg-admin-bg px-1 py-0.5">admin</code> / <code class="rounded bg-admin-bg px-1 py-0.5">password</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user