From 93c65cd1556e67f7f1d2f47d908cc1981aba58cc Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 4 May 2026 10:43:07 +0200 Subject: [PATCH] feat(auth): implement JWT authentication and user registration - Added JWT authentication with token generation and validation. - Introduced user registration functionality with email and password. - Integrated Argon2 for password hashing. - Created SQLite user repository for user data persistence. - Updated application context to include user repository and configuration settings. - Added environment variable support for JWT secret and registration allowance. - Enhanced error handling for unauthorized access and validation errors. - Updated presentation layer to handle login and registration requests. --- .cargo/config.toml | 2 + .env.example | 5 + ...425e43d09a6df97c335ac347f7cfd61acd171.json | 32 ++++ ...32d9ea7eabc99d9f1a44694e5d10762606f82.json | 12 ++ Cargo.lock | 141 ++++++++++++++++++ Cargo.toml | 1 + crates/adapters/auth/Cargo.toml | 7 + crates/adapters/auth/src/lib.rs | 107 ++++++++++++- .../adapters/sqlite/migrations/0002_users.sql | 6 + crates/adapters/sqlite/src/lib.rs | 3 + crates/adapters/sqlite/src/users.rs | 76 ++++++++++ crates/application/src/commands.rs | 10 ++ crates/application/src/config.rs | 13 ++ crates/application/src/context.rs | 6 +- crates/application/src/lib.rs | 1 + crates/application/src/use_cases/login.rs | 39 +++++ crates/application/src/use_cases/mod.rs | 2 + crates/application/src/use_cases/register.rs | 18 +++ crates/domain/src/errors.rs | 3 + crates/domain/src/models/mod.rs | 4 + crates/domain/src/ports.rs | 19 ++- crates/presentation/Cargo.toml | 1 + crates/presentation/src/dtos.rs | 9 ++ crates/presentation/src/errors.rs | 1 + crates/presentation/src/extractors.rs | 17 ++- crates/presentation/src/handlers.rs | 36 ++++- crates/presentation/src/main.rs | 46 +++--- crates/presentation/src/routes.rs | 3 +- crates/presentation/tests/api_test.rs | 64 ++++---- 29 files changed, 599 insertions(+), 85 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json create mode 100644 .sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json create mode 100644 crates/adapters/sqlite/migrations/0002_users.sql create mode 100644 crates/adapters/sqlite/src/users.rs create mode 100644 crates/application/src/config.rs create mode 100644 crates/application/src/use_cases/login.rs create mode 100644 crates/application/src/use_cases/register.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..19f0a3a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +SQLX_OFFLINE = "true" diff --git a/.env.example b/.env.example index e69de29..55bdb81 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=sqlite:./dev.db +PORT=3000 +JWT_SECRET= +JWT_TTL_SECONDS= +ALLOW_REGISTRATION=true \ No newline at end of file diff --git a/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json b/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json new file mode 100644 index 0000000..80aefc3 --- /dev/null +++ b/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, password_hash FROM users WHERE email = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171" +} diff --git a/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json b/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json new file mode 100644 index 0000000..78a50ea --- /dev/null +++ b/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82" +} diff --git a/Cargo.lock b/Cargo.lock index 7e7cafc..7b68908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,18 @@ dependencies = [ "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]] name = "askama" version = "0.16.0" @@ -125,8 +137,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" name = "auth" version = "0.1.0" dependencies = [ + "anyhow", + "argon2", "async-trait", + "chrono", "domain", + "jsonwebtoken", + "rand_core", + "serde", + "uuid", ] [[package]] @@ -229,6 +248,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -374,6 +402,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -592,8 +629,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -929,6 +968,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1071,6 +1125,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1087,6 +1151,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.46" @@ -1152,6 +1222,27 @@ dependencies = [ "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", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1215,6 +1306,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1235,6 +1332,7 @@ dependencies = [ "axum", "chrono", "domain", + "dotenvy", "http-body-util", "serde", "serde_json", @@ -1574,6 +1672,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1915,6 +2025,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index eab3eae..5b5e9eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ resolver = "2" [workspace.dependencies] tokio = { version = "1.0", features = ["full"] } +dotenvy = "0.15" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index ce75e1f..437ffc6 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -6,3 +6,10 @@ edition = "2024" [dependencies] async-trait = { workspace = true } domain = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +jsonwebtoken = "9" +argon2 = { version = "0.5", features = ["std"] } +rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 47cf196..10e7dbb 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -1,13 +1,104 @@ use async_trait::async_trait; -use domain::{errors::DomainError, ports::AuthService, value_objects::UserId}; +use argon2::{ + Argon2, + password_hash::{PasswordHasher as _, PasswordVerifier, SaltString}, +}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; -pub struct StubAuthService; +use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + value_objects::{PasswordHash, UserId}, +}; -#[async_trait] -impl AuthService for StubAuthService { - async fn validate_token(&self, _token: &str) -> Result { - Err(DomainError::InfrastructureError( - "auth service not implemented".into(), - )) +pub struct AuthConfig { + secret: String, + ttl_seconds: u64, +} + +impl AuthConfig { + pub fn from_env() -> anyhow::Result { + let secret = std::env::var("JWT_SECRET") + .map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?; + if secret.is_empty() { + anyhow::bail!("JWT_SECRET must not be empty"); + } + let ttl_seconds = std::env::var("JWT_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(86400u64); + Ok(Self { secret, ttl_seconds }) + } +} + +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: u64, +} + +pub struct JwtAuthService { + config: AuthConfig, +} + +impl JwtAuthService { + pub fn new(config: AuthConfig) -> Self { + Self { config } + } +} + +#[async_trait] +impl AuthService for JwtAuthService { + async fn generate_token(&self, user_id: &UserId) -> Result { + let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64); + let claims = Claims { + sub: user_id.value().to_string(), + exp: expires_at.timestamp() as u64, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.config.secret.as_bytes()), + ) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(GeneratedToken { token, expires_at }) + } + + async fn validate_token(&self, token: &str) -> Result { + let data = decode::( + token, + &DecodingKey::from_secret(self.config.secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?; + let uuid = Uuid::parse_str(&data.claims.sub) + .map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?; + Ok(UserId::from_uuid(uuid)) + } +} + +pub struct Argon2PasswordHasher; + +#[async_trait] +impl PasswordHasher for Argon2PasswordHasher { + async fn hash(&self, plain_password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(plain_password.as_bytes(), &salt) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .to_string(); + PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + + async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result { + let parsed = argon2::password_hash::PasswordHash::new(hash.value()) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(Argon2::default() + .verify_password(plain_password.as_bytes(), &parsed) + .is_ok()) } } diff --git a/crates/adapters/sqlite/migrations/0002_users.sql b/crates/adapters/sqlite/migrations/0002_users.sql new file mode 100644 index 0000000..bec028f --- /dev/null +++ b/crates/adapters/sqlite/migrations/0002_users.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index d6757b1..c0d3100 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -13,9 +13,12 @@ use sqlx::SqlitePool; mod migrations; mod models; +mod users; use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str}; +pub use users::SqliteUserRepository; + pub struct SqliteMovieRepository { pool: SqlitePool, } diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs new file mode 100644 index 0000000..02235fb --- /dev/null +++ b/crates/adapters/sqlite/src/users.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use chrono::Utc; +use sqlx::SqlitePool; + +use domain::{ + errors::DomainError, + models::User, + ports::UserRepository, + value_objects::{Email, PasswordHash, UserId}, +}; + +pub struct SqliteUserRepository { + pool: SqlitePool, +} + +impl SqliteUserRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl UserRepository for SqliteUserRepository { + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let email_str = email.value(); + let row = sqlx::query!( + "SELECT id, email, password_hash FROM users WHERE email = ?", + email_str + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + match row { + None => Ok(None), + Some(r) => { + let id = uuid::Uuid::parse_str(&r.id) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let email = Email::new(r.email) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let hash = PasswordHash::new(r.password_hash) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash))) + } + } + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + let id = user.id().value().to_string(); + let email = user.email().value(); + let hash = user.password_hash().value(); + let created_at = Utc::now().to_rfc3339(); + + let result = sqlx::query!( + "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)", + id, + email, + hash, + created_at + ) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + if result.rows_affected() == 0 { + return Err(DomainError::ValidationError("Email already registered".into())); + } + + Ok(()) + } +} diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index e9ceaaa..9266965 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -18,3 +18,13 @@ pub struct SyncPosterCommand { pub movie_id: Uuid, pub external_metadata_id: String, } + +pub struct LoginCommand { + pub email: String, + pub password: String, +} + +pub struct RegisterCommand { + pub email: String, + pub password: String, +} diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs new file mode 100644 index 0000000..84c3cce --- /dev/null +++ b/crates/application/src/config.rs @@ -0,0 +1,13 @@ +#[derive(Clone)] +pub struct AppConfig { + pub allow_registration: bool, +} + +impl AppConfig { + pub fn from_env() -> Self { + let allow_registration = std::env::var("ALLOW_REGISTRATION") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + Self { allow_registration } + } +} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index dc33435..823d25b 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use domain::ports::{ AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher, - PosterFetcherClient, PosterStorage, + PosterFetcherClient, PosterStorage, UserRepository, }; +use crate::config::AppConfig; + #[derive(Clone)] pub struct AppContext { pub repository: Arc, @@ -14,4 +16,6 @@ pub struct AppContext { pub event_publisher: Arc, pub auth_service: Arc, pub password_hasher: Arc, + pub user_repository: Arc, + pub config: AppConfig, } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 682dd6f..50e5f19 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod config; pub mod context; pub mod ports; pub mod queries; diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs new file mode 100644 index 0000000..015747b --- /dev/null +++ b/crates/application/src/use_cases/login.rs @@ -0,0 +1,39 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use domain::{errors::DomainError, value_objects::Email}; + +use crate::{commands::LoginCommand, context::AppContext}; + +pub struct LoginResult { + pub token: String, + pub user_id: Uuid, + pub email: String, + pub expires_at: DateTime, +} + +pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result { + let email = Email::new(cmd.email)?; + let user = ctx + .user_repository + .find_by_email(&email) + .await? + .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?; + + let valid = ctx + .password_hasher + .verify(&cmd.password, user.password_hash()) + .await?; + if !valid { + return Err(DomainError::Unauthorized("Invalid credentials".into())); + } + + let generated = ctx.auth_service.generate_token(user.id()).await?; + + Ok(LoginResult { + token: generated.token, + user_id: user.id().value(), + email: user.email().value().to_string(), + expires_at: generated.expires_at, + }) +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index 0dfe21e..8ba7998 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -1,4 +1,6 @@ pub mod get_diary; pub mod get_review_history; pub mod log_review; +pub mod login; +pub mod register; pub mod sync_poster; diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs new file mode 100644 index 0000000..d36259e --- /dev/null +++ b/crates/application/src/use_cases/register.rs @@ -0,0 +1,18 @@ +use domain::{errors::DomainError, models::User, value_objects::Email}; + +use crate::{commands::RegisterCommand, context::AppContext}; + +pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> { + if !ctx.config.allow_registration { + return Err(DomainError::Unauthorized("Registration is disabled".into())); + } + + let email = Email::new(cmd.email)?; + + if ctx.user_repository.find_by_email(&email).await?.is_some() { + return Err(DomainError::ValidationError("Email already registered".into())); + } + + let hash = ctx.password_hasher.hash(&cmd.password).await?; + ctx.user_repository.save(&User::new(email, hash)).await +} diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs index 4ed2e42..cfbcd15 100644 --- a/crates/domain/src/errors.rs +++ b/crates/domain/src/errors.rs @@ -13,4 +13,7 @@ pub enum DomainError { #[error("Infrastructure failure: {0}")] InfrastructureError(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index cb85804..c7a9501 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -250,6 +250,10 @@ impl User { } } + pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self { + Self { id, email, password_hash } + } + pub fn update_password(&mut self, new_hash: PasswordHash) { self.password_hash = new_hash; } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 14366c1..bfefd74 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1,12 +1,13 @@ use async_trait::async_trait; +use chrono::{DateTime, Utc}; use crate::{ errors::DomainError, events::DomainEvent, - models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, + models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated}, value_objects::{ - ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, ReleaseYear, - UserId, + Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, + ReleaseYear, UserId, }, }; @@ -61,11 +62,23 @@ pub trait PosterStorage: Send + Sync { async fn get_poster(&self, poster_path: &PosterPath) -> Result, DomainError>; } +pub struct GeneratedToken { + pub token: String, + pub expires_at: DateTime, +} + #[async_trait] pub trait AuthService: Send + Sync { + async fn generate_token(&self, user_id: &UserId) -> Result; async fn validate_token(&self, token: &str) -> Result; } +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; +} + #[async_trait] pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index b85ca7f..201dccb 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -14,6 +14,7 @@ thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tokio = { workspace = true } +dotenvy = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } async-trait = { workspace = true } diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index fc5d81b..a6f2730 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -78,6 +78,15 @@ pub struct LoginRequest { #[derive(Serialize)] pub struct LoginResponse { pub token: String, + pub user_id: Uuid, + pub email: String, + pub expires_at: String, +} + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password: String, } #[cfg(test)] diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs index 10fc5bf..099d7e3 100644 --- a/crates/presentation/src/errors.rs +++ b/crates/presentation/src/errors.rs @@ -18,6 +18,7 @@ impl IntoResponse for ApiError { DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()), DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg), DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), DomainError::InfrastructureError(_) => { tracing::error!("Internal Infrastructure Error: {:?}", self.0); ( diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index b9d3701..1498745 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -23,8 +23,8 @@ where .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or_else(|| { - ApiError(DomainError::ValidationError( - "Missing auth token".into(), + ApiError(DomainError::Unauthorized( + "Missing or invalid auth token".into(), )) })?; let user_id = app_state @@ -58,10 +58,9 @@ mod tests { } #[tokio::test] - async fn missing_auth_header_returns_400() { + async fn missing_auth_header_returns_401() { use std::sync::Arc; use application::context::AppContext; - use auth::StubAuthService; struct PanicRepo; #[async_trait::async_trait] @@ -80,12 +79,14 @@ mod tests { fn render_diary_page(&self, _: &domain::models::collections::Paginated) -> Result { panic!() } } - struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; + struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo; #[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::value_objects::ExternalMetadataId) -> Result { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result, domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result, domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result { panic!() } } + #[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { panic!() } } + #[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } } let state = crate::state::AppState { app_ctx: AppContext { @@ -94,8 +95,10 @@ mod tests { poster_fetcher: Arc::new(PanicFetcher), poster_storage: Arc::new(PanicStorage), event_publisher: Arc::new(PanicEvent), - auth_service: Arc::new(StubAuthService), + auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), + user_repository: Arc::new(PanicUserRepo), + config: application::config::AppConfig { allow_registration: false }, }, html_renderer: Arc::new(PanicRenderer), }; @@ -111,6 +114,6 @@ mod tests { .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 51e3231..1789736 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -86,9 +86,9 @@ pub mod api { use uuid::Uuid; use application::{ - commands::{LogReviewCommand, SyncPosterCommand}, + commands::{LoginCommand, LogReviewCommand, RegisterCommand, SyncPosterCommand}, queries::{GetDiaryQuery, GetReviewHistoryQuery}, - use_cases::{get_diary, get_review_history, log_review, sync_poster}, + use_cases::{get_diary, get_review_history, log_review, login as login_uc, register as register_uc, sync_poster}, }; use domain::{ errors::DomainError, @@ -100,7 +100,7 @@ pub mod api { use crate::{ dtos::{ DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse, - LogReviewRequest, MovieDto, ReviewDto, ReviewHistoryResponse, + LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, }, errors::ApiError, extractors::AuthenticatedUser, @@ -219,12 +219,32 @@ pub mod api { } pub async fn login( - State(_state): State, - Json(_req): Json, - ) -> Json { - Json(LoginResponse { - token: "stub-token".to_string(), + State(state): State, + Json(req): Json, + ) -> Result, ApiError> { + let result = login_uc::execute(&state.app_ctx, LoginCommand { + email: req.email, + password: req.password, }) + .await?; + Ok(Json(LoginResponse { + token: result.token, + user_id: result.user_id, + email: result.email, + expires_at: result.expires_at.to_rfc3339(), + })) + } + + pub async fn register( + State(state): State, + Json(req): Json, + ) -> Result { + register_uc::execute(&state.app_ctx, RegisterCommand { + email: req.email, + password: req.password, + }) + .await?; + Ok(StatusCode::CREATED) } fn movie_to_dto(movie: &Movie) -> MovieDto { diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 3c481bc..cb5c6d5 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -6,16 +6,16 @@ use domain::{ errors::DomainError, events::DomainEvent, models::Movie, - ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, - value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, + ports::{EventPublisher, MetadataClient, PosterFetcherClient, PosterStorage}, + value_objects::{ExternalMetadataId, MovieId, PosterPath, PosterUrl}, }; use sqlx::SqlitePool; use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use application::context::AppContext; -use auth::StubAuthService; -use sqlite::SqliteMovieRepository; +use application::{config::AppConfig, context::AppContext}; +use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService}; +use sqlite::{SqliteMovieRepository, SqliteUserRepository}; use template_askama::AskamaHtmlRenderer; use presentation::{routes, state::AppState}; @@ -81,25 +81,9 @@ impl EventPublisher for StubEventPublisher { } } -struct StubPasswordHasher; - -#[async_trait] -impl PasswordHasher for StubPasswordHasher { - async fn hash(&self, _plain: &str) -> Result { - Err(DomainError::InfrastructureError( - "password hasher not implemented".into(), - )) - } - - async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { - Err(DomainError::InfrastructureError( - "password hasher not implemented".into(), - )) - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); init_tracing(); let state = wire_dependencies() @@ -116,24 +100,32 @@ async fn main() -> anyhow::Result<()> { } async fn wire_dependencies() -> anyhow::Result { + let auth_config = AuthConfig::from_env()?; + let app_config = AppConfig::from_env(); + let pool = SqlitePool::connect("sqlite://reviews.db") .await .context("Failed to connect to SQLite database")?; - let repo = SqliteMovieRepository::new(pool); - repo.migrate() + let movie_repo = SqliteMovieRepository::new(pool.clone()); + movie_repo + .migrate() .await .map_err(|e| anyhow::anyhow!("{}", e)) .context("Database migration failed")?; + let user_repo = SqliteUserRepository::new(pool); + let app_ctx = AppContext { - repository: Arc::new(repo), + repository: Arc::new(movie_repo), metadata_client: Arc::new(StubMetadataClient), poster_fetcher: Arc::new(StubPosterFetcher), poster_storage: Arc::new(StubPosterStorage), event_publisher: Arc::new(StubEventPublisher), - auth_service: Arc::new(StubAuthService), - password_hasher: Arc::new(StubPasswordHasher), + auth_service: Arc::new(JwtAuthService::new(auth_config)), + password_hasher: Arc::new(Argon2PasswordHasher), + user_repository: Arc::new(user_repo), + config: app_config, }; Ok(AppState { diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index e31c3cf..8dc3af1 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -32,6 +32,7 @@ fn api_routes() -> Router { "/movies/{id}/sync-poster", routing::post(handlers::api::sync_poster), ) - .route("/auth/login", routing::post(handlers::api::login)), + .route("/auth/login", routing::post(handlers::api::login)) + .route("/auth/register", routing::post(handlers::api::register)), ) } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 316d953..5f29aca 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -1,8 +1,7 @@ use std::sync::Arc; -use application::context::AppContext; +use application::{config::AppConfig, context::AppContext}; use async_trait::async_trait; -use auth::StubAuthService; use axum::{ Router, body::Body, @@ -11,9 +10,14 @@ use axum::{ use domain::{ errors::DomainError, events::DomainEvent, - models::Movie, - ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, - value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, + models::{Movie, User}, + ports::{ + AuthService, EventPublisher, GeneratedToken, MetadataClient, PasswordHasher, + PosterFetcherClient, PosterStorage, UserRepository, + }, + value_objects::{ + Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId, + }, }; use http_body_util::BodyExt; use presentation::{routes, state::AppState}; @@ -36,10 +40,7 @@ impl MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result { panic!("metadata not wired in tests") } - async fn get_poster_url( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { + async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result, DomainError> { panic!() } } @@ -66,12 +67,22 @@ impl PosterStorage for PanicStorage { struct PanicHasher; #[async_trait] impl PasswordHasher for PanicHasher { - async fn hash(&self, _: &str) -> Result { - panic!() - } - async fn verify(&self, _: &str, _: &PasswordHash) -> Result { - panic!() - } + async fn hash(&self, _: &str) -> Result { panic!() } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { panic!() } +} + +struct PanicAuth; +#[async_trait] +impl AuthService for PanicAuth { + async fn generate_token(&self, _: &UserId) -> Result { panic!() } + async fn validate_token(&self, _: &str) -> Result { panic!() } +} + +struct NobodyUserRepo; +#[async_trait] +impl UserRepository for NobodyUserRepo { + async fn find_by_email(&self, _: &Email) -> Result, DomainError> { Ok(None) } + async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } } async fn test_app() -> Router { @@ -88,8 +99,10 @@ async fn test_app() -> Router { poster_fetcher: Arc::new(PanicFetcher), poster_storage: Arc::new(PanicStorage), event_publisher: Arc::new(NoopEventPublisher), - auth_service: Arc::new(StubAuthService), + auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), + user_repository: Arc::new(NobodyUserRepo), + config: AppConfig { allow_registration: false }, }, html_renderer: Arc::new(AskamaHtmlRenderer::new()), }; @@ -101,12 +114,7 @@ async fn test_app() -> Router { async fn get_api_diary_returns_empty_list() { let app = test_app().await; let response = app - .oneshot( - Request::builder() - .uri("/api/diary") - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap()) .await .unwrap(); @@ -122,7 +130,7 @@ async fn get_api_diary_returns_empty_list() { } #[tokio::test] -async fn post_api_reviews_without_auth_returns_400() { +async fn post_api_reviews_without_auth_returns_401() { let app = test_app().await; let response = app .oneshot( @@ -138,11 +146,11 @@ async fn post_api_reviews_without_auth_returns_400() { .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] -async fn post_api_auth_login_returns_stub_token() { +async fn post_api_auth_login_unknown_user_returns_401() { let app = test_app().await; let response = app .oneshot( @@ -156,9 +164,5 @@ async fn post_api_auth_login_returns_stub_token() { .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(json["token"], "stub-token"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); }