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.
This commit is contained in:
2026-05-04 10:43:07 +02:00
parent ba42d3d445
commit 93c65cd155
29 changed files with 599 additions and 85 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -0,0 +1,5 @@
DATABASE_URL=sqlite:./dev.db
PORT=3000
JWT_SECRET=
JWT_TTL_SECONDS=
ALLOW_REGISTRATION=true

View File

@@ -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"
}

View File

@@ -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"
}

141
Cargo.lock generated
View File

@@ -42,6 +42,18 @@ dependencies = [
"uuid", "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]] [[package]]
name = "askama" name = "askama"
version = "0.16.0" version = "0.16.0"
@@ -125,8 +137,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
name = "auth" name = "auth"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"argon2",
"async-trait", "async-trait",
"chrono",
"domain", "domain",
"jsonwebtoken",
"rand_core",
"serde",
"uuid",
] ]
[[package]] [[package]]
@@ -229,6 +248,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -374,6 +402,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -592,8 +629,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -929,6 +968,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1071,6 +1125,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -1087,6 +1151,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1152,6 +1222,27 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1215,6 +1306,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1235,6 +1332,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"domain", "domain",
"dotenvy",
"http-body-util", "http-body-util",
"serde", "serde",
"serde_json", "serde_json",
@@ -1574,6 +1672,18 @@ dependencies = [
"rand_core", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1915,6 +2025,37 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"

View File

@@ -13,6 +13,7 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
anyhow = "1.0" anyhow = "1.0"

View File

@@ -6,3 +6,10 @@ edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
domain = { 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"] }

View File

@@ -1,13 +1,104 @@
use async_trait::async_trait; 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] pub struct AuthConfig {
impl AuthService for StubAuthService { secret: String,
async fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> { ttl_seconds: u64,
Err(DomainError::InfrastructureError( }
"auth service not implemented".into(),
)) impl AuthConfig {
pub fn from_env() -> anyhow::Result<Self> {
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<GeneratedToken, DomainError> {
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<UserId, DomainError> {
let data = decode::<Claims>(
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<PasswordHash, DomainError> {
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<bool, DomainError> {
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())
} }
} }

View File

@@ -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
);

View File

@@ -13,9 +13,12 @@ use sqlx::SqlitePool;
mod migrations; mod migrations;
mod models; mod models;
mod users;
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str}; use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
pub use users::SqliteUserRepository;
pub struct SqliteMovieRepository { pub struct SqliteMovieRepository {
pool: SqlitePool, pool: SqlitePool,
} }

View File

@@ -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<Option<User>, 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(())
}
}

View File

@@ -18,3 +18,13 @@ pub struct SyncPosterCommand {
pub movie_id: Uuid, pub movie_id: Uuid,
pub external_metadata_id: String, pub external_metadata_id: String,
} }
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RegisterCommand {
pub email: String,
pub password: String,
}

View File

@@ -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 }
}
}

View File

@@ -2,9 +2,11 @@ use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher, AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
PosterFetcherClient, PosterStorage, PosterFetcherClient, PosterStorage, UserRepository,
}; };
use crate::config::AppConfig;
#[derive(Clone)] #[derive(Clone)]
pub struct AppContext { pub struct AppContext {
pub repository: Arc<dyn MovieRepository>, pub repository: Arc<dyn MovieRepository>,
@@ -14,4 +16,6 @@ pub struct AppContext {
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
pub auth_service: Arc<dyn AuthService>, pub auth_service: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>, pub password_hasher: Arc<dyn PasswordHasher>,
pub user_repository: Arc<dyn UserRepository>,
pub config: AppConfig,
} }

View File

@@ -1,4 +1,5 @@
pub mod commands; pub mod commands;
pub mod config;
pub mod context; pub mod context;
pub mod ports; pub mod ports;
pub mod queries; pub mod queries;

View File

@@ -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<Utc>,
}
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
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,
})
}

View File

@@ -1,4 +1,6 @@
pub mod get_diary; pub mod get_diary;
pub mod get_review_history; pub mod get_review_history;
pub mod log_review; pub mod log_review;
pub mod login;
pub mod register;
pub mod sync_poster; pub mod sync_poster;

View File

@@ -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
}

View File

@@ -13,4 +13,7 @@ pub enum DomainError {
#[error("Infrastructure failure: {0}")] #[error("Infrastructure failure: {0}")]
InfrastructureError(String), InfrastructureError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
} }

View File

@@ -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) { pub fn update_password(&mut self, new_hash: PasswordHash) {
self.password_hash = new_hash; self.password_hash = new_hash;
} }

View File

@@ -1,12 +1,13 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc};
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
value_objects::{ value_objects::{
ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, ReleaseYear, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
UserId, ReleaseYear, UserId,
}, },
}; };
@@ -61,11 +62,23 @@ pub trait PosterStorage: Send + Sync {
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>; async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
} }
pub struct GeneratedToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[async_trait] #[async_trait]
pub trait AuthService: Send + Sync { pub trait AuthService: Send + Sync {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>; async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
} }
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
}
#[async_trait] #[async_trait]
pub trait EventPublisher: Send + Sync { pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;

View File

@@ -14,6 +14,7 @@ thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
dotenvy = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }

View File

@@ -78,6 +78,15 @@ pub struct LoginRequest {
#[derive(Serialize)] #[derive(Serialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub token: String, 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)] #[cfg(test)]

View File

@@ -18,6 +18,7 @@ impl IntoResponse for ApiError {
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()), DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg), DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
DomainError::InfrastructureError(_) => { DomainError::InfrastructureError(_) => {
tracing::error!("Internal Infrastructure Error: {:?}", self.0); tracing::error!("Internal Infrastructure Error: {:?}", self.0);
( (

View File

@@ -23,8 +23,8 @@ where
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ")) .and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| { .ok_or_else(|| {
ApiError(DomainError::ValidationError( ApiError(DomainError::Unauthorized(
"Missing auth token".into(), "Missing or invalid auth token".into(),
)) ))
})?; })?;
let user_id = app_state let user_id = app_state
@@ -58,10 +58,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn missing_auth_header_returns_400() { async fn missing_auth_header_returns_401() {
use std::sync::Arc; use std::sync::Arc;
use application::context::AppContext; use application::context::AppContext;
use auth::StubAuthService;
struct PanicRepo; struct PanicRepo;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -80,12 +79,14 @@ mod tests {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { panic!() } fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { 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<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, 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<Vec<u8>, 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<Vec<u8>, 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<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, 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<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, 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::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<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } }
let state = crate::state::AppState { let state = crate::state::AppState {
app_ctx: AppContext { app_ctx: AppContext {
@@ -94,8 +95,10 @@ mod tests {
poster_fetcher: Arc::new(PanicFetcher), poster_fetcher: Arc::new(PanicFetcher),
poster_storage: Arc::new(PanicStorage), poster_storage: Arc::new(PanicStorage),
event_publisher: Arc::new(PanicEvent), event_publisher: Arc::new(PanicEvent),
auth_service: Arc::new(StubAuthService), auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher), password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(PanicUserRepo),
config: application::config::AppConfig { allow_registration: false },
}, },
html_renderer: Arc::new(PanicRenderer), html_renderer: Arc::new(PanicRenderer),
}; };
@@ -111,6 +114,6 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
} }

View File

@@ -86,9 +86,9 @@ pub mod api {
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
commands::{LogReviewCommand, SyncPosterCommand}, commands::{LoginCommand, LogReviewCommand, RegisterCommand, SyncPosterCommand},
queries::{GetDiaryQuery, GetReviewHistoryQuery}, 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::{ use domain::{
errors::DomainError, errors::DomainError,
@@ -100,7 +100,7 @@ pub mod api {
use crate::{ use crate::{
dtos::{ dtos::{
DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse,
LogReviewRequest, MovieDto, ReviewDto, ReviewHistoryResponse, LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse,
}, },
errors::ApiError, errors::ApiError,
extractors::AuthenticatedUser, extractors::AuthenticatedUser,
@@ -219,12 +219,32 @@ pub mod api {
} }
pub async fn login( pub async fn login(
State(_state): State<AppState>, State(state): State<AppState>,
Json(_req): Json<LoginRequest>, Json(req): Json<LoginRequest>,
) -> Json<LoginResponse> { ) -> Result<Json<LoginResponse>, ApiError> {
Json(LoginResponse { let result = login_uc::execute(&state.app_ctx, LoginCommand {
token: "stub-token".to_string(), 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<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
register_uc::execute(&state.app_ctx, RegisterCommand {
email: req.email,
password: req.password,
})
.await?;
Ok(StatusCode::CREATED)
} }
fn movie_to_dto(movie: &Movie) -> MovieDto { fn movie_to_dto(movie: &Movie) -> MovieDto {

View File

@@ -6,16 +6,16 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::Movie, models::Movie,
ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, ports::{EventPublisher, MetadataClient, PosterFetcherClient, PosterStorage},
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, value_objects::{ExternalMetadataId, MovieId, PosterPath, PosterUrl},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::context::AppContext; use application::{config::AppConfig, context::AppContext};
use auth::StubAuthService; use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
use sqlite::SqliteMovieRepository; use sqlite::{SqliteMovieRepository, SqliteUserRepository};
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
use presentation::{routes, state::AppState}; 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<PasswordHash, DomainError> {
Err(DomainError::InfrastructureError(
"password hasher not implemented".into(),
))
}
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
Err(DomainError::InfrastructureError(
"password hasher not implemented".into(),
))
}
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
init_tracing(); init_tracing();
let state = wire_dependencies() let state = wire_dependencies()
@@ -116,24 +100,32 @@ async fn main() -> anyhow::Result<()> {
} }
async fn wire_dependencies() -> anyhow::Result<AppState> { async fn wire_dependencies() -> anyhow::Result<AppState> {
let auth_config = AuthConfig::from_env()?;
let app_config = AppConfig::from_env();
let pool = SqlitePool::connect("sqlite://reviews.db") let pool = SqlitePool::connect("sqlite://reviews.db")
.await .await
.context("Failed to connect to SQLite database")?; .context("Failed to connect to SQLite database")?;
let repo = SqliteMovieRepository::new(pool); let movie_repo = SqliteMovieRepository::new(pool.clone());
repo.migrate() movie_repo
.migrate()
.await .await
.map_err(|e| anyhow::anyhow!("{}", e)) .map_err(|e| anyhow::anyhow!("{}", e))
.context("Database migration failed")?; .context("Database migration failed")?;
let user_repo = SqliteUserRepository::new(pool);
let app_ctx = AppContext { let app_ctx = AppContext {
repository: Arc::new(repo), repository: Arc::new(movie_repo),
metadata_client: Arc::new(StubMetadataClient), metadata_client: Arc::new(StubMetadataClient),
poster_fetcher: Arc::new(StubPosterFetcher), poster_fetcher: Arc::new(StubPosterFetcher),
poster_storage: Arc::new(StubPosterStorage), poster_storage: Arc::new(StubPosterStorage),
event_publisher: Arc::new(StubEventPublisher), event_publisher: Arc::new(StubEventPublisher),
auth_service: Arc::new(StubAuthService), auth_service: Arc::new(JwtAuthService::new(auth_config)),
password_hasher: Arc::new(StubPasswordHasher), password_hasher: Arc::new(Argon2PasswordHasher),
user_repository: Arc::new(user_repo),
config: app_config,
}; };
Ok(AppState { Ok(AppState {

View File

@@ -32,6 +32,7 @@ fn api_routes() -> Router<AppState> {
"/movies/{id}/sync-poster", "/movies/{id}/sync-poster",
routing::post(handlers::api::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)),
) )
} }

View File

@@ -1,8 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use application::context::AppContext; use application::{config::AppConfig, context::AppContext};
use async_trait::async_trait; use async_trait::async_trait;
use auth::StubAuthService;
use axum::{ use axum::{
Router, Router,
body::Body, body::Body,
@@ -11,9 +10,14 @@ use axum::{
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::Movie, models::{Movie, User},
ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, ports::{
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, AuthService, EventPublisher, GeneratedToken, MetadataClient, PasswordHasher,
PosterFetcherClient, PosterStorage, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
},
}; };
use http_body_util::BodyExt; use http_body_util::BodyExt;
use presentation::{routes, state::AppState}; use presentation::{routes, state::AppState};
@@ -36,10 +40,7 @@ impl MetadataClient for PanicMeta {
async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result<Movie, DomainError> { async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result<Movie, DomainError> {
panic!("metadata not wired in tests") panic!("metadata not wired in tests")
} }
async fn get_poster_url( async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!() panic!()
} }
} }
@@ -66,12 +67,22 @@ impl PosterStorage for PanicStorage {
struct PanicHasher; struct PanicHasher;
#[async_trait] #[async_trait]
impl PasswordHasher for PanicHasher { impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
panic!() async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
} }
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!() struct PanicAuth;
} #[async_trait]
impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
}
struct NobodyUserRepo;
#[async_trait]
impl UserRepository for NobodyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
} }
async fn test_app() -> Router { async fn test_app() -> Router {
@@ -88,8 +99,10 @@ async fn test_app() -> Router {
poster_fetcher: Arc::new(PanicFetcher), poster_fetcher: Arc::new(PanicFetcher),
poster_storage: Arc::new(PanicStorage), poster_storage: Arc::new(PanicStorage),
event_publisher: Arc::new(NoopEventPublisher), event_publisher: Arc::new(NoopEventPublisher),
auth_service: Arc::new(StubAuthService), auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher), password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(NobodyUserRepo),
config: AppConfig { allow_registration: false },
}, },
html_renderer: Arc::new(AskamaHtmlRenderer::new()), html_renderer: Arc::new(AskamaHtmlRenderer::new()),
}; };
@@ -101,12 +114,7 @@ async fn test_app() -> Router {
async fn get_api_diary_returns_empty_list() { async fn get_api_diary_returns_empty_list() {
let app = test_app().await; let app = test_app().await;
let response = app let response = app
.oneshot( .oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
Request::builder()
.uri("/api/diary")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
@@ -122,7 +130,7 @@ async fn get_api_diary_returns_empty_list() {
} }
#[tokio::test] #[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 app = test_app().await;
let response = app let response = app
.oneshot( .oneshot(
@@ -138,11 +146,11 @@ async fn post_api_reviews_without_auth_returns_400() {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test] #[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 app = test_app().await;
let response = app let response = app
.oneshot( .oneshot(
@@ -156,9 +164,5 @@ async fn post_api_auth_login_returns_stub_token() {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
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");
} }