feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs

- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
This commit is contained in:
2026-07-11 21:28:52 +02:00
parent 13031347cc
commit 7bd27d9b9c
50 changed files with 1604 additions and 213 deletions

View File

@@ -5,4 +5,5 @@ edition = "2024"
[dependencies]
uuid = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,18 @@
pub struct RegisterCommand {
pub email: String,
pub username: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RefreshCommand {
pub refresh_token: String,
}
pub struct LogoutCommand {
pub refresh_token: String,
}

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
pub struct RegisterDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub allow_registration: bool,
}
pub struct LoginDeps {
pub user_repo: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct RefreshDeps {
pub auth_service: Arc<dyn AuthService>,
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
pub refresh_ttl_seconds: u64,
}
pub struct LogoutDeps {
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
}

View File

@@ -0,0 +1,53 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::{Email, UserId};
use uuid::Uuid;
use super::commands::LoginCommand;
use super::deps::LoginDeps;
pub struct LoginResult {
pub access_token: String,
pub refresh_token: String,
pub user_id: UserId,
pub expires_at: String,
}
pub async fn execute(deps: &LoginDeps, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(&cmd.email)?;
let user = deps
.user_repo
.find_by_email(&email)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid credentials".into()))?;
let valid = deps
.password_hasher
.verify(&cmd.password, user.password_hash())
.await?;
if !valid {
return Err(DomainError::Unauthorized("invalid credentials".into()));
}
let generated = deps.auth_service.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let session = RefreshSession {
id: Uuid::new_v4(),
user_id: *user.id(),
token: refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&session).await?;
Ok(LoginResult {
access_token: generated.token,
refresh_token,
user_id: *user.id(),
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,8 @@
use domain::errors::DomainError;
use super::commands::LogoutCommand;
use super::deps::LogoutDeps;
pub async fn execute(deps: &LogoutDeps, cmd: LogoutCommand) -> Result<(), DomainError> {
deps.refresh_repo.revoke(&cmd.refresh_token).await
}

View File

@@ -0,0 +1,6 @@
pub mod commands;
pub mod deps;
pub mod login;
pub mod logout;
pub mod refresh;
pub mod register;

View File

@@ -0,0 +1,50 @@
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::models::RefreshSession;
use uuid::Uuid;
use super::commands::RefreshCommand;
use super::deps::RefreshDeps;
pub struct RefreshResult {
pub access_token: String,
pub refresh_token: String,
pub expires_at: String,
}
pub async fn execute(
deps: &RefreshDeps,
cmd: RefreshCommand,
) -> Result<RefreshResult, DomainError> {
let session = deps
.refresh_repo
.get_by_token(&cmd.refresh_token)
.await?
.ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?;
if session.expires_at < Utc::now() {
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
return Err(DomainError::Unauthorized("refresh token expired".into()));
}
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
let generated = deps.auth_service.generate_token(&session.user_id).await?;
let new_refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
let new_session = RefreshSession {
id: Uuid::new_v4(),
user_id: session.user_id,
token: new_refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
deps.refresh_repo.create(&new_session).await?;
Ok(RefreshResult {
access_token: generated.token,
refresh_token: new_refresh_token,
expires_at: generated.expires_at.to_rfc3339(),
})
}

View File

@@ -0,0 +1,34 @@
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, Password, Username};
use super::commands::RegisterCommand;
use super::deps::RegisterDeps;
pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> {
if !deps.allow_registration {
return Err(DomainError::Unauthorized("registration is disabled".into()));
}
let password = Password::new(&cmd.password)?;
let email = Email::new(&cmd.email)?;
let username = Username::new(&cmd.username)?;
if deps.user_repo.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError(
"email already registered".into(),
));
}
if deps.user_repo.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError(
"username already taken".into(),
));
}
let hash = deps.password_hasher.hash(password.value()).await?;
let user = User::new(email, username, hash);
deps.user_repo.save(&user).await?;
Ok(())
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod songs;
pub mod tabs;