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

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