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:
@@ -8,3 +8,5 @@ thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
email_address = "0.2"
|
||||
|
||||
@@ -10,4 +10,10 @@ pub enum DomainError {
|
||||
|
||||
#[error("Infrastructure failure: {0}")]
|
||||
InfrastructureError(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ pub mod value_objects;
|
||||
|
||||
pub use errors::DomainError;
|
||||
pub use models::{
|
||||
ChordPosition, LyricLine, Section, SectionKind, Song, SongMeta, SongSummary, StoredSong,
|
||||
song_preview_chords,
|
||||
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
|
||||
SongSummary, StoredSong, User, song_preview_chords,
|
||||
};
|
||||
pub use ports::{
|
||||
FetchError, ParseError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort,
|
||||
TabSource,
|
||||
AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
|
||||
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
|
||||
};
|
||||
pub use services::{ChordTransposer, TransposeError};
|
||||
pub use value_objects::{Chord, Note, SortField, SortOrder};
|
||||
pub use value_objects::{
|
||||
Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod refresh_session;
|
||||
pub mod song;
|
||||
pub mod user;
|
||||
|
||||
pub use refresh_session::*;
|
||||
pub use song::*;
|
||||
pub use user::*;
|
||||
|
||||
17
crates/domain/src/models/refresh_session.rs
Normal file
17
crates/domain/src/models/refresh_session.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
pub struct GeneratedToken {
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct RefreshSession {
|
||||
pub id: Uuid,
|
||||
pub user_id: UserId,
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
50
crates/domain/src/models/user.rs
Normal file
50
crates/domain/src/models/user.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
id: UserId,
|
||||
email: Email,
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: UserId,
|
||||
email: Email,
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &UserId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &Email {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &Username {
|
||||
&self.username
|
||||
}
|
||||
|
||||
pub fn password_hash(&self) -> &PasswordHash {
|
||||
&self.password_hash
|
||||
}
|
||||
}
|
||||
34
crates/domain/src/ports/auth.rs
Normal file
34
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
use crate::models::{GeneratedToken, RefreshSession, User};
|
||||
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
|
||||
#[async_trait]
|
||||
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_trait]
|
||||
pub trait PasswordHasher: Send + Sync {
|
||||
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
|
||||
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RefreshSessionRepository: Send + Sync {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod repository;
|
||||
pub mod tab_source;
|
||||
|
||||
pub use auth::*;
|
||||
pub use repository::*;
|
||||
pub use tab_source::*;
|
||||
|
||||
18
crates/domain/src/value_objects/ids.rs
Normal file
18
crates/domain/src/value_objects/ids.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct UserId(Uuid);
|
||||
|
||||
impl UserId {
|
||||
pub fn generate() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
mod chord;
|
||||
mod ids;
|
||||
mod note;
|
||||
mod sorting;
|
||||
mod user;
|
||||
|
||||
pub use chord::*;
|
||||
pub use ids::*;
|
||||
pub use note::*;
|
||||
pub use sorting::*;
|
||||
pub use user::*;
|
||||
|
||||
107
crates/domain/src/value_objects/user.rs
Normal file
107
crates/domain/src/value_objects/user.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
pub fn new(email: &str) -> Result<Self, DomainError> {
|
||||
let trimmed = email.trim().to_lowercase();
|
||||
if !email_address::EmailAddress::is_valid(&trimmed) {
|
||||
return Err(DomainError::ValidationError(format!(
|
||||
"invalid email: {trimmed}"
|
||||
)));
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Email {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Email({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl Username {
|
||||
pub fn new(username: &str) -> Result<Self, DomainError> {
|
||||
let normalized = username.trim().to_lowercase();
|
||||
if normalized.len() < 2 || normalized.len() > 30 {
|
||||
return Err(DomainError::ValidationError(
|
||||
"username must be 2-30 characters".into(),
|
||||
));
|
||||
}
|
||||
if !normalized
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"username may only contain alphanumeric characters, underscores, and dashes".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(normalized))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Username {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Username({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn new(hash: String) -> Result<Self, DomainError> {
|
||||
if hash.is_empty() {
|
||||
return Err(DomainError::ValidationError(
|
||||
"password hash cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(hash))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PasswordHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "PasswordHash([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
pub fn new(password: &str) -> Result<Self, DomainError> {
|
||||
if password.len() < 8 {
|
||||
return Err(DomainError::ValidationError(
|
||||
"password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(password.to_string()))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Password([REDACTED])")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user