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

View File

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

View File

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

View File

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

View File

@@ -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<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::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::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::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 {
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);
}
}

View File

@@ -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<AppState>,
Json(_req): Json<LoginRequest>,
) -> Json<LoginResponse> {
Json(LoginResponse {
token: "stub-token".to_string(),
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<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 {

View File

@@ -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<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]
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<AppState> {
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 {

View File

@@ -32,6 +32,7 @@ fn api_routes() -> Router<AppState> {
"/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)),
)
}

View File

@@ -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<Movie, DomainError> {
panic!("metadata not wired in tests")
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
}
@@ -66,12 +67,22 @@ impl PosterStorage for PanicStorage {
struct PanicHasher;
#[async_trait]
impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
async fn hash(&self, _: &str) -> Result<PasswordHash, 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 {
@@ -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);
}