feat: Centralize axum-login authentication logic in infra and introduce configurable database and session settings.
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
102
api/src/auth.rs
102
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<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
pub fn new(user_repo: Arc<dyn UserRepository>) -> 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<Option<Self::User>, 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<Self>) -> Result<Option<Self::User>, 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<AuthBackend>;
|
||||
#[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<InfraSessionStore>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> {
|
||||
let backend = AuthBackend::new(user_repo);
|
||||
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
Ok(auth_layer)
|
||||
) -> Result<AuthManagerLayer, ApiError> {
|
||||
infra::auth::backend::setup_auth_layer(session_layer, user_repo)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
"db-sqlx",
|
||||
"sessions-db"
|
||||
"sessions-db",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
|
||||
@@ -21,6 +32,7 @@ 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"] }
|
||||
@@ -30,3 +42,8 @@ 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 }
|
||||
|
||||
117
infra/src/auth/mod.rs
Normal file
117
infra/src/auth/mod.rs
Normal file
@@ -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<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
pub fn new(user_repo: Arc<dyn UserRepository>) -> 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<Option<Self::User>, 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<Self>,
|
||||
) -> Result<Option<Self::User>, 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<AuthBackend>;
|
||||
pub type AuthManagerLayer = axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>;
|
||||
|
||||
pub async fn setup_auth_layer(
|
||||
session_layer: SessionManagerLayer<InfraSessionStore>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
) -> Result<AuthManagerLayer, AuthError> {
|
||||
let backend = AuthBackend::new(user_repo);
|
||||
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
Ok(auth_layer)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub use k_core::session::store::InfraSessionStore;
|
||||
pub use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
|
||||
Reference in New Issue
Block a user