diff --git a/Cargo.lock b/Cargo.lock index a7e9b54..70d75f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,16 +1405,24 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" dependencies = [ "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", "js-sys", + "p256", + "p384", "pem", - "ring", + "rand 0.8.5", + "rsa", "serde", "serde_json", + "sha2", + "signature", "simple_asn1", ] diff --git a/README.md b/README.md index 622828b..c0afebb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,20 @@ A production-ready, modular Rust API template for K-Suite applications, followin ## Quick Start -### 1. Clone and Configure +### Option 1: Use cargo-generate (Recommended) + +```bash +cargo generate --git https://github.com/GKaszewski/k-template.git +``` + +You'll be prompted to choose: +- **Project name**: Your new service name +- **Database**: `sqlite` or `postgres` +- **Session auth**: Enable cookie-based sessions +- **JWT auth**: Enable Bearer token authentication +- **OIDC**: Enable OpenID Connect integration + +### Option 2: Clone directly ```bash git clone https://github.com/GKaszewski/k-template.git my-api @@ -22,7 +35,7 @@ cp .env.example .env # Edit .env with your configuration ``` -### 2. Run +### Run ```bash # Development (with hot reload via cargo-watch) diff --git a/api/Cargo.toml b/api/Cargo.toml index 1aec6a4..402bdc3 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" default-run = "api" [features] -default = ["sqlite", "auth-axum-login", "auth-oidc", "auth-jwt"] +default = ["sqlite"] sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] auth-axum-login = ["infra/auth-axum-login"] diff --git a/api/Cargo.toml.template b/api/Cargo.toml.template new file mode 100644 index 0000000..0db268c --- /dev/null +++ b/api/Cargo.toml.template @@ -0,0 +1,63 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2024" +default-run = "api" + +[features] +default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] +sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"] +postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"] +auth-axum-login = ["infra/auth-axum-login"] +auth-oidc = ["infra/auth-oidc"] +auth-jwt = ["infra/auth-jwt"] +auth-full = ["auth-axum-login", "auth-oidc", "auth-jwt"] + +[dependencies] +k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ + "logging", + "db-sqlx", + "{{database}}", + "http", + "auth", + "sessions-db", +] } +domain = { path = "../domain" } +infra = { path = "../infra", default-features = false, features = ["{{database}}"] } + +#Web framework +axum = { version = "0.8.8", features = ["macros"] } +tower = "0.5.2" +tower-http = { version = "0.6.2", features = ["cors", "trace"] } + +# Authentication +# Moved to infra +tower-sessions-sqlx-store = { version = "0.15", features = ["{{database}}"] } +# password-auth removed +time = "0.3" +async-trait = "0.1.89" + +# Async runtime +tokio = { version = "1.48.0", features = ["full"] } + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0" + +# Validation via domain newtypes (Email, Password) + +# Error handling +thiserror = "2.0.17" +anyhow = "1.0" + +# Utilities +chrono = { version = "0.4.42", features = ["serde"] } +uuid = { version = "1.19.0", features = ["v4", "serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } + +dotenvy = "0.15.7" +config = "0.15.19" +tower-sessions = "0.14.0" diff --git a/api/src/dto.rs b/api/src/dto.rs index 99a2d88..3b3355a 100644 --- a/api/src/dto.rs +++ b/api/src/dto.rs @@ -10,6 +10,7 @@ use uuid::Uuid; /// Login request with validated email and password newtypes #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct LoginRequest { /// Email is validated on deserialization pub email: Email, @@ -19,6 +20,7 @@ pub struct LoginRequest { /// Register request with validated email and password newtypes #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct RegisterRequest { /// Email is validated on deserialization pub email: Email, diff --git a/api/src/extractors.rs b/api/src/extractors.rs index bf7193b..d1cae72 100644 --- a/api/src/extractors.rs +++ b/api/src/extractors.rs @@ -131,20 +131,3 @@ async fn try_session_auth(parts: &mut Parts) -> Result, ApiError> { Ok(None) } - -/// Fallback for when auth-axum-login is not enabled -#[cfg(not(feature = "auth-axum-login"))] -async fn try_session_auth(_parts: &mut Parts) -> Result, ApiError> { - Ok(None) -} - -/// Fallback for when auth-jwt is not enabled but auth mode requires it -#[cfg(not(feature = "auth-jwt"))] -async fn try_jwt_auth(_parts: &mut Parts, state: &AppState) -> Result, ApiError> { - if matches!(state.config.auth_mode, AuthMode::Jwt) { - return Err(ApiError::Internal( - "JWT auth mode configured but auth-jwt feature not enabled".to_string(), - )); - } - Ok(None) -} diff --git a/api/src/routes/auth.rs b/api/src/routes/auth.rs index 382a00c..2e737af 100644 --- a/api/src/routes/auth.rs +++ b/api/src/routes/auth.rs @@ -154,10 +154,13 @@ async fn register( ))); } - // Using email as subject for local auth for now + // Hash password + let password_hash = infra::auth::backend::hash_password(payload.password.as_ref()); + + // Create user with password let user = state .user_service - .find_or_create(&email.as_ref().to_string(), email.as_ref()) + .create_local(email.as_ref(), &password_hash) .await?; let auth_mode = state.config.auth_mode; diff --git a/cargo-generate.toml b/cargo-generate.toml index 731c5eb..bf14f35 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -1,21 +1,46 @@ [template] cargo_generate_version = ">=0.21.0" -ignore = [".git", "target", ".idea", ".vscode"] +ignore = [ + ".git", + "target", + ".idea", + ".vscode", + "data.db", + "api/Cargo.toml", + "infra/Cargo.toml", +] -[placeholders] -project_name = { type = "string", prompt = "Project name" } -database = { type = "string", prompt = "Database type", choices = [ - "sqlite", - "postgres", -], default = "sqlite" } +[filenames] +"api/Cargo.toml.template" = "api/Cargo.toml" +"infra/Cargo.toml.template" = "infra/Cargo.toml" -[conditional] -# Conditional dependencies based on database choice -sqlite = { condition = "database == 'sqlite'", ignore = [ - "infra/src/user_repository_postgres.rs", - "migrations_postgres", -] } -postgres = { condition = "database == 'postgres'", ignore = [ - "infra/src/user_repository_sqlite.rs", - "migrations", -] } +[placeholders.project_name] +type = "string" +prompt = "Project name" + +[placeholders.database] +type = "string" +prompt = "Database type" +choices = ["sqlite", "postgres"] +default = "sqlite" + +[placeholders.auth_session] +type = "bool" +prompt = "Enable session-based authentication (cookies)?" +default = true + +[placeholders.auth_jwt] +type = "bool" +prompt = "Enable JWT authentication (Bearer tokens)?" +default = true + +[placeholders.auth_oidc] +type = "bool" +prompt = "Enable OIDC integration (Login with Google, etc.)?" +default = true + +[conditional.'database == "sqlite"'] +ignore = ["migrations_postgres"] + +[conditional.'database == "postgres"'] +ignore = ["migrations_sqlite"] diff --git a/domain/src/services.rs b/domain/src/services.rs index 25e8e8b..e38077e 100644 --- a/domain/src/services.rs +++ b/domain/src/services.rs @@ -54,4 +54,11 @@ impl UserService { pub async fn find_by_email(&self, email: &str) -> DomainResult> { self.user_repository.find_by_email(email).await } + + pub async fn create_local(&self, email: &str, password_hash: &str) -> DomainResult { + let email = Email::try_from(email)?; + let user = User::new_local(email, password_hash); + self.user_repository.save(&user).await?; + Ok(user) + } } diff --git a/infra/Cargo.toml b/infra/Cargo.toml index df0b625..d37d898 100644 --- a/infra/Cargo.toml +++ b/infra/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [features] -default = ["sqlite", "broker-nats"] +default = ["sqlite"] sqlite = [ "sqlx/sqlite", "k-core/sqlite", @@ -51,5 +51,10 @@ axum-login = { version = "0.18", optional = true } password-auth = { version = "1.0", optional = true } openidconnect = { version = "4.0.1", optional = true } url = { version = "2.5.8", optional = true } -jsonwebtoken = { version = "9.3", optional = true } -# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true } +jsonwebtoken = { version = "10.2.0", features = [ + "sha2", + "p256", + "hmac", + "rsa", + "rust_crypto", +], optional = true } diff --git a/infra/Cargo.toml.template b/infra/Cargo.toml.template new file mode 100644 index 0000000..8da6944 --- /dev/null +++ b/infra/Cargo.toml.template @@ -0,0 +1,55 @@ +[package] +name = "infra" +version = "0.1.0" +edition = "2024" + +[features] +default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}] +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"] +auth-oidc = ["dep:openidconnect", "dep:url"] +auth-jwt = ["dep:jsonwebtoken"] + +[dependencies] +k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ + "logging", + "db-sqlx", + "sessions-db", +] } +domain = { path = "../domain" } + +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 } +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 } +openidconnect = { version = "4.0.1", optional = true } +url = { version = "2.5.8", optional = true } +jsonwebtoken = { version = "9.3", optional = true } +# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true } diff --git a/infra/src/auth/mod.rs b/infra/src/auth/mod.rs index 9f91882..8e7fe9e 100644 --- a/infra/src/auth/mod.rs +++ b/infra/src/auth/mod.rs @@ -114,6 +114,10 @@ pub mod backend { let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); Ok(auth_layer) } + + pub fn hash_password(password: &str) -> String { + password_auth::generate_hash(password) + } } #[cfg(feature = "auth-oidc")]