Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
295 lines
9.6 KiB
Rust
295 lines
9.6 KiB
Rust
#![allow(
|
|
clippy::cast_possible_truncation,
|
|
clippy::cast_sign_loss,
|
|
clippy::cast_precision_loss,
|
|
clippy::cast_possible_wrap
|
|
)]
|
|
//! Authentication middleware — session-lock based auth for Axum.
|
|
//!
|
|
//! Provides:
|
|
//! - [`SessionAuthLayer`]: a tower [`Layer`] that injects session validation
|
|
//! - [`SessionIdentity`]: extracted from validated requests
|
|
//! - [`validate_session`]: low-level session existence/validity check
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::task::{Context, Poll};
|
|
|
|
use axum::extract::FromRequestParts;
|
|
use axum::http::header;
|
|
use axum::http::request::Parts;
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::response::{IntoResponse, Response};
|
|
use serde::{Deserialize, Serialize};
|
|
use tower::{Layer, Service};
|
|
use zesdex_entities::seaorm::common::store::Store;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SessionIdentity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Identity extracted from a validated session token / lock.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionIdentity {
|
|
/// The validated session id (from `X-Session-Id`).
|
|
pub session_id: String,
|
|
/// User-Agent header value, if present.
|
|
pub user_agent: String,
|
|
/// Unix-epoch timestamp (seconds) when the session was first seen by
|
|
/// this middleware.
|
|
pub connected_at: i64,
|
|
}
|
|
|
|
impl SessionIdentity {
|
|
/// Create a new identity from a validated session id.
|
|
fn new(session_id: String, user_agent: String) -> Self {
|
|
let connected_at = chrono::Utc::now().timestamp();
|
|
Self {
|
|
session_id,
|
|
user_agent,
|
|
connected_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extractor: pull the identity from request extensions.
|
|
///
|
|
/// If the identity has not been inserted by the middleware the request is
|
|
/// rejected with 401 Unauthorized.
|
|
impl<S: Send + Sync> FromRequestParts<S> for SessionIdentity {
|
|
type Rejection = Response;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
parts
|
|
.extensions
|
|
.get::<SessionIdentity>()
|
|
.cloned()
|
|
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "session identity not found").into_response())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SessionAuthLayer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Tower [`Layer`] that produces [`SessionAuthMiddleware`] services.
|
|
///
|
|
/// Wraps every request with session validation: if the `X-Session-Id`
|
|
/// header points to a valid session, the request passes through and a
|
|
/// [`SessionIdentity`] is injected into the request extensions. Otherwise
|
|
/// a 401 response is returned immediately.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionAuthLayer {
|
|
store: Arc<Store>,
|
|
}
|
|
|
|
impl SessionAuthLayer {
|
|
/// Create a new layer backed by the given [`Store`].
|
|
pub fn new(store: Store) -> Self {
|
|
Self {
|
|
store: Arc::new(store),
|
|
}
|
|
}
|
|
|
|
/// Convenience constructor using `Store::new()`.
|
|
pub fn default() -> Self {
|
|
Self::new(Store::new())
|
|
}
|
|
}
|
|
|
|
impl<S> Layer<S> for SessionAuthLayer {
|
|
type Service = SessionAuthMiddleware<S>;
|
|
|
|
fn layer(&self, inner: S) -> Self::Service {
|
|
SessionAuthMiddleware {
|
|
inner,
|
|
store: Arc::clone(&self.store),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SessionAuthMiddleware
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Tower [`Service`] that validates `X-Session-Id` before forwarding.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionAuthMiddleware<S> {
|
|
inner: S,
|
|
store: Arc<Store>,
|
|
}
|
|
|
|
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
|
where
|
|
S: Service<Request<ReqBody>, Response = Response> + Send + 'static,
|
|
S::Future: Send + 'static,
|
|
ReqBody: Send + 'static,
|
|
{
|
|
type Response = S::Response;
|
|
type Error = S::Error;
|
|
type Future =
|
|
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
|
|
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
self.inner.poll_ready(cx)
|
|
}
|
|
|
|
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
|
|
let store = Arc::clone(&self.store);
|
|
|
|
// Extract session id from header.
|
|
let session_id = req
|
|
.headers()
|
|
.get("X-Session-Id")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
let session_id = match session_id {
|
|
Some(id) if !id.is_empty() => id,
|
|
_ => {
|
|
let resp = (StatusCode::UNAUTHORIZED, "missing X-Session-Id header")
|
|
.into_response();
|
|
return Box::pin(async move { Ok(resp) });
|
|
}
|
|
};
|
|
|
|
// Validate session.
|
|
if let Err(e) = validate_session(&session_id, &store) {
|
|
let resp = (
|
|
StatusCode::UNAUTHORIZED,
|
|
format!("session validation failed: {e}"),
|
|
)
|
|
.into_response();
|
|
return Box::pin(async move { Ok(resp) });
|
|
}
|
|
let user_agent = req
|
|
.headers()
|
|
.get(header::USER_AGENT)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let identity = SessionIdentity::new(session_id, user_agent);
|
|
req.extensions_mut().insert(identity);
|
|
|
|
let fut = self.inner.call(req);
|
|
Box::pin(async move { fut.await })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: `require_session` (convenience middleware function)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Axum middleware function that validates `X-Session-Id` against the
|
|
/// [`Store`] extracted from request extensions.
|
|
///
|
|
/// This is an alternative to [`SessionAuthLayer`] when you want to attach
|
|
/// auth to a specific route group via `axum::middleware::from_fn_with_state`.
|
|
pub async fn require_session(
|
|
store: axum::extract::State<Store>,
|
|
mut req: Request<axum::body::Body>,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
let session_id = req
|
|
.headers()
|
|
.get("X-Session-Id")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
let session_id = match session_id {
|
|
Some(id) if !id.is_empty() => id,
|
|
_ => {
|
|
return (StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response();
|
|
}
|
|
};
|
|
|
|
if let Err(e) = validate_session(&session_id, &store) {
|
|
return (StatusCode::UNAUTHORIZED, format!("session validation failed: {e}")).into_response();
|
|
}
|
|
let user_agent = req
|
|
.headers()
|
|
.get(header::USER_AGENT)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let identity = SessionIdentity::new(session_id, user_agent);
|
|
req.extensions_mut().insert(identity);
|
|
next.run(req).await
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Check whether a session lock exists and is valid, returning the
|
|
/// associated [`SessionIdentity`].
|
|
///
|
|
/// Validation logic:
|
|
/// 1. Verify the session id is not a path-traversal attack.
|
|
/// 2. Check that `<store.base_dir>/sessions/<id>/session.json` exists.
|
|
/// 3. Deserialise the session metadata to confirm it is well-formed.
|
|
///
|
|
/// This is a synchronous, CPU-light check so it can be called directly
|
|
/// inside tower service impls without spawning a blocking task.
|
|
pub fn validate_session(session_id: &str, store: &Store) -> anyhow::Result<SessionIdentity> {
|
|
// Directory-traversal prevention.
|
|
if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") {
|
|
anyhow::bail!("invalid session id: must not contain path separators");
|
|
}
|
|
|
|
let session_path = store
|
|
.base_dir
|
|
.join("sessions")
|
|
.join(session_id)
|
|
.join("session.json");
|
|
|
|
if !session_path.exists() {
|
|
anyhow::bail!("session not found: {session_id}");
|
|
}
|
|
|
|
let _data = std::fs::read_to_string(&session_path)?;
|
|
// We verify the JSON is well-formed by deserialising it.
|
|
let _session: serde_json::Value = serde_json::from_str(&_data)?;
|
|
|
|
let user_agent = String::new();
|
|
Ok(SessionIdentity::new(session_id.to_string(), user_agent))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_validate_session_rejects_path_traversal() {
|
|
let store = Store::new();
|
|
assert!(validate_session("../etc/passwd", &store).is_err());
|
|
assert!(validate_session("foo/bar", &store).is_err());
|
|
assert!(validate_session("foo\\bar", &store).is_err());
|
|
assert!(validate_session("..", &store).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_session_nonexistent() {
|
|
let store = Store::new();
|
|
let result = validate_session("nonexistent-session-id", &store);
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("session not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_identity_creation() {
|
|
let identity = SessionIdentity::new("sess-123".into(), "test-agent".into());
|
|
assert_eq!(identity.session_id, "sess-123");
|
|
assert_eq!(identity.user_agent, "test-agent");
|
|
assert!(identity.connected_at > 0);
|
|
}
|
|
}
|