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:
18
crates/application/src/auth/commands.rs
Normal file
18
crates/application/src/auth/commands.rs
Normal 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,
|
||||
}
|
||||
27
crates/application/src/auth/deps.rs
Normal file
27
crates/application/src/auth/deps.rs
Normal 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>,
|
||||
}
|
||||
53
crates/application/src/auth/login.rs
Normal file
53
crates/application/src/auth/login.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
8
crates/application/src/auth/logout.rs
Normal file
8
crates/application/src/auth/logout.rs
Normal 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
|
||||
}
|
||||
6
crates/application/src/auth/mod.rs
Normal file
6
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod refresh;
|
||||
pub mod register;
|
||||
50
crates/application/src/auth/refresh.rs
Normal file
50
crates/application/src/auth/refresh.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
34
crates/application/src/auth/register.rs
Normal file
34
crates/application/src/auth/register.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod songs;
|
||||
pub mod tabs;
|
||||
|
||||
Reference in New Issue
Block a user