diff --git a/Cargo.lock b/Cargo.lock index 3285585..f8609d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,14 +39,12 @@ dependencies = [ "anyhow", "async-trait", "axum", - "axum-login", "chrono", "config", "domain", "dotenvy", "infra", "k-core", - "password-auth", "serde", "serde_json", "thiserror 2.0.17", @@ -54,7 +52,6 @@ dependencies = [ "tokio", "tower", "tower-http", - "tower-sessions", "tower-sessions-sqlx-store", "tracing", "tracing-subscriber", @@ -1175,18 +1172,22 @@ dependencies = [ name = "infra" version = "0.1.0" dependencies = [ + "anyhow", "async-nats", "async-trait", + "axum-login", "chrono", "domain", "futures-core", "futures-util", "k-core", + "password-auth", "serde", "serde_json", "sqlx", "thiserror 2.0.17", "tokio", + "tower-sessions", "tower-sessions-sqlx-store", "tracing", "uuid", diff --git a/api/Cargo.toml b/api/Cargo.toml index 521aee7..d01bd69 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,15 +5,10 @@ edition = "2024" default-run = "api" [features] -default = ["sqlite"] -sqlite = [ - "infra/sqlite", - "tower-sessions-sqlx-store/sqlite", -] -postgres = [ - "infra/postgres", - "tower-sessions-sqlx-store/postgres", -] +default = ["sqlite", "auth-axum-login"] +sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] +postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] +auth-axum-login = ["infra/auth-axum-login"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ @@ -21,12 +16,11 @@ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features "db-sqlx", "sqlite", "http", - "auth","sessions-db" + "auth", + "sessions-db", ] } domain = { path = "../domain" } -infra = { path = "../infra", default-features = false, features = [ - "sqlite", -] } +infra = { path = "../infra", default-features = false, features = ["sqlite"] } #Web framework axum = { version = "0.8.8", features = ["macros"] } @@ -34,10 +28,9 @@ tower = "0.5.2" tower-http = { version = "0.6.2", features = ["cors", "trace"] } # Authentication -axum-login = "0.18" -tower-sessions = "0.14" +# Moved to infra tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] } -password-auth = "1.0" +# password-auth removed time = "0.3" async-trait = "0.1.89" @@ -65,4 +58,3 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } dotenvy = "0.15.7" config = "0.15.19" - diff --git a/api/src/auth.rs b/api/src/auth.rs index a877fbc..e0d7245 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -1,101 +1,23 @@ -//! Authentication logic using axum-login +//! Authentication logic +//! +//! Proxies to infra implementation if enabled. use std::sync::Arc; -use axum_login::{AuthnBackend, UserId}; -use infra::session_store::InfraSessionStore; -use password_auth::verify_password; -use serde::{Deserialize, Serialize}; -use tower_sessions::SessionManagerLayer; -use uuid::Uuid; +use domain::UserRepository; +use infra::session_store::{InfraSessionStore, SessionManagerLayer}; use crate::error::ApiError; -use domain::{User, UserRepository}; -/// Wrapper around domain User to implement AuthUser -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthUser(pub User); - -impl axum_login::AuthUser for AuthUser { - type Id = Uuid; - - fn id(&self) -> Self::Id { - self.0.id - } - - fn session_auth_hash(&self) -> &[u8] { - // Use password hash to invalidate sessions if password changes - self.0 - .password_hash - .as_ref() - .map(|s| s.as_bytes()) - .unwrap_or(&[]) - } -} - -#[derive(Clone)] -pub struct AuthBackend { - pub user_repo: Arc, -} - -impl AuthBackend { - pub fn new(user_repo: Arc) -> Self { - Self { user_repo } - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Credentials { - pub email: String, - pub password: String, -} - -impl AuthnBackend for AuthBackend { - type User = AuthUser; - type Credentials = Credentials; - type Error = ApiError; - - async fn authenticate( - &self, - creds: Self::Credentials, - ) -> Result, Self::Error> { - let user = self - .user_repo - .find_by_email(&creds.email) - .await - .map_err(|e| ApiError::internal(e.to_string()))?; - - if let Some(user) = user { - if let Some(hash) = &user.password_hash { - // Verify password - if verify_password(&creds.password, hash).is_ok() { - return Ok(Some(AuthUser(user))); - } - } - } - - Ok(None) - } - - async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { - let user = self - .user_repo - .find_by_id(*user_id) - .await - .map_err(|e| ApiError::internal(e.to_string()))?; - - Ok(user.map(AuthUser)) - } -} - -pub type AuthSession = axum_login::AuthSession; +#[cfg(feature = "auth-axum-login")] +pub use infra::auth::backend::{AuthManagerLayer, AuthSession, AuthUser, Credentials}; +#[cfg(feature = "auth-axum-login")] pub async fn setup_auth_layer( session_layer: SessionManagerLayer, user_repo: Arc, -) -> Result, ApiError> { - let backend = AuthBackend::new(user_repo); - - let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); - Ok(auth_layer) +) -> Result { + infra::auth::backend::setup_auth_layer(session_layer, user_repo) + .await + .map_err(|e| ApiError::Internal(e.to_string())) } diff --git a/api/src/config.rs b/api/src/config.rs index aa06dbd..156f473 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -17,6 +17,27 @@ pub struct Config { #[serde(default = "default_host")] pub host: String, + + #[serde(default = "default_secure_cookie")] + pub secure_cookie: bool, + + #[serde(default = "default_db_max_connections")] + pub db_max_connections: u32, + + #[serde(default = "default_db_min_connections")] + pub db_min_connections: u32, +} + +fn default_secure_cookie() -> bool { + false +} + +fn default_db_max_connections() -> u32 { + 5 +} + +fn default_db_min_connections() -> u32 { + 1 } fn default_port() -> u16 { @@ -62,12 +83,30 @@ impl Config { .filter(|s| !s.is_empty()) .collect(); + let secure_cookie = env::var("SECURE_COOKIE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(false); + + let db_max_connections = env::var("DB_MAX_CONNECTIONS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5); + + let db_min_connections = env::var("DB_MIN_CONNECTIONS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + Self { host, port, database_url, session_secret, cors_allowed_origins, + secure_cookie, + db_max_connections, + db_min_connections, } } } diff --git a/api/src/main.rs b/api/src/main.rs index 9eb06db..9ffad01 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -6,12 +6,12 @@ use domain::UserService; use infra::factory::build_session_store; use infra::factory::build_user_repository; use infra::run_migrations; +use infra::session_store::{Expiry, SessionManagerLayer}; use k_core::http::server::ServerConfig; use k_core::http::server::apply_standard_middleware; use k_core::logging; use time::Duration; use tokio::net::TcpListener; -use tower_sessions::{Expiry, SessionManagerLayer}; use tracing::info; mod auth; @@ -37,8 +37,8 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Connecting to database: {}", config.database_url); let db_config = k_core::db::DatabaseConfig { url: config.database_url.clone(), - max_connections: 5, - min_connections: 1, + max_connections: config.db_max_connections, + min_connections: config.db_min_connections, acquire_timeout: StdDuration::from_secs(30), }; @@ -60,7 +60,7 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!(e))?; let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) // Set to true in prod + .with_secure(config.secure_cookie) .with_expiry(Expiry::OnInactivity(Duration::days(7))); let auth_layer = setup_auth_layer(session_layer, user_repo).await?; diff --git a/api/src/routes/auth.rs b/api/src/routes/auth.rs index ee003d7..a506e3b 100644 --- a/api/src/routes/auth.rs +++ b/api/src/routes/auth.rs @@ -31,10 +31,10 @@ async fn login( password: payload.password, }) .await + .map_err(|e| ApiError::Internal(e.to_string()))? { - Ok(Some(user)) => user, - Ok(None) => return Err(ApiError::Validation("Invalid credentials".to_string())), - Err(_) => return Err(ApiError::Internal("Authentication failed".to_string())), + Some(user) => user, + None => return Err(ApiError::Validation("Invalid credentials".to_string())), }; auth_session diff --git a/infra/Cargo.toml b/infra/Cargo.toml index 99297d3..fc49537 100644 --- a/infra/Cargo.toml +++ b/infra/Cargo.toml @@ -5,15 +5,26 @@ edition = "2024" [features] default = ["sqlite", "broker-nats"] -sqlite = ["sqlx/sqlite", "k-core/sqlite", "tower-sessions-sqlx-store", "k-core/sessions-db"] -postgres = ["sqlx/postgres", "k-core/postgres", "tower-sessions-sqlx-store", "k-core/sessions-db"] +sqlite = [ + "sqlx/sqlite", + "k-core/sqlite", + "tower-sessions-sqlx-store", + "k-core/sessions-db", +] +postgres = [ + "sqlx/postgres", + "k-core/postgres", + "tower-sessions-sqlx-store", + "k-core/sessions-db", +] broker-nats = ["dep:futures-util", "k-core/broker-nats"] +auth-axum-login = ["dep:axum-login", "dep:password-auth"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ - "logging", + "logging", "db-sqlx", - "sessions-db" + "sessions-db", ] } domain = { path = "../domain" } @@ -21,12 +32,18 @@ async-trait = "0.1.89" chrono = { version = "0.4.42", features = ["serde"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] } thiserror = "2.0.17" +anyhow = "1.0" tokio = { version = "1.48.0", features = ["full"] } tracing = "0.1" uuid = { version = "1.19.0", features = ["v4", "serde"] } -tower-sessions-sqlx-store = { version = "0.15.0", optional = true} +tower-sessions-sqlx-store = { version = "0.15.0", optional = true } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } async-nats = { version = "0.45", optional = true } futures-util = { version = "0.3", optional = true } futures-core = "0.3" +tower-sessions = "0.14" + +# Auth dependencies (optional) +axum-login = { version = "0.18", optional = true } +password-auth = { version = "1.0", optional = true } diff --git a/infra/src/auth/mod.rs b/infra/src/auth/mod.rs new file mode 100644 index 0000000..e7c3fb9 --- /dev/null +++ b/infra/src/auth/mod.rs @@ -0,0 +1,117 @@ +//! Authentication infrastructure +//! +//! This module contains the concrete implementation of authentication mechanisms. + +#[cfg(feature = "auth-axum-login")] +pub mod backend { + use std::sync::Arc; + + use axum_login::{AuthnBackend, UserId}; + use password_auth::verify_password; + use serde::{Deserialize, Serialize}; + use tower_sessions::SessionManagerLayer; + use uuid::Uuid; + + use domain::{User, UserRepository}; + + // We use the same session store as defined in infra + use crate::session_store::InfraSessionStore; + + /// Wrapper around domain User to implement AuthUser + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct AuthUser(pub User); + + impl axum_login::AuthUser for AuthUser { + type Id = Uuid; + + fn id(&self) -> Self::Id { + self.0.id + } + + fn session_auth_hash(&self) -> &[u8] { + // Use password hash to invalidate sessions if password changes + self.0 + .password_hash + .as_ref() + .map(|s| s.as_bytes()) + .unwrap_or(&[]) + } + } + + #[derive(Clone)] + pub struct AuthBackend { + pub user_repo: Arc, + } + + impl AuthBackend { + pub fn new(user_repo: Arc) -> Self { + Self { user_repo } + } + } + + #[derive(Clone, Debug, Deserialize)] + pub struct Credentials { + pub email: String, + pub password: String, + } + + #[derive(Debug, thiserror::Error)] + pub enum AuthError { + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + } + + impl AuthnBackend for AuthBackend { + type User = AuthUser; + type Credentials = Credentials; + type Error = AuthError; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let user = self + .user_repo + .find_by_email(&creds.email) + .await + .map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?; + + if let Some(user) = user { + if let Some(hash) = &user.password_hash { + // Verify password + if verify_password(&creds.password, hash).is_ok() { + return Ok(Some(AuthUser(user))); + } + } + } + + Ok(None) + } + + async fn get_user( + &self, + user_id: &UserId, + ) -> Result, Self::Error> { + let user = self + .user_repo + .find_by_id(*user_id) + .await + .map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?; + + Ok(user.map(AuthUser)) + } + } + + pub type AuthSession = axum_login::AuthSession; + pub type AuthManagerLayer = axum_login::AuthManagerLayer; + + pub async fn setup_auth_layer( + session_layer: SessionManagerLayer, + user_repo: Arc, + ) -> Result { + let backend = AuthBackend::new(user_repo); + + let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); + Ok(auth_layer) + } +} diff --git a/infra/src/lib.rs b/infra/src/lib.rs index 8d46f61..53dbbab 100644 --- a/infra/src/lib.rs +++ b/infra/src/lib.rs @@ -14,6 +14,7 @@ //! - [`db::create_pool`] - Create a database connection pool //! - [`db::run_migrations`] - Run database migrations +pub mod auth; pub mod db; pub mod factory; pub mod session_store; diff --git a/infra/src/session_store.rs b/infra/src/session_store.rs index e9f5bee..edb657f 100644 --- a/infra/src/session_store.rs +++ b/infra/src/session_store.rs @@ -1 +1,2 @@ pub use k_core::session::store::InfraSessionStore; +pub use tower_sessions::{Expiry, SessionManagerLayer};