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:
14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { workspace = true }
|
||||
100
crates/adapters/auth/src/lib.rs
Normal file
100
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::GeneratedToken;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
pub struct JwtAuthService {
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
|
||||
Self {
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
ttl_seconds: ttl_seconds as i64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::AuthService for JwtAuthService {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
|
||||
let claims = Claims {
|
||||
sub: user_id.value().to_string(),
|
||||
exp: expires_at.timestamp() as usize,
|
||||
};
|
||||
let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(GeneratedToken { token, expires_at })
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
let data =
|
||||
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
|
||||
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
|
||||
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argon2PasswordHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::PasswordHasher for Argon2PasswordHasher {
|
||||
async fn hash(
|
||||
&self,
|
||||
plain_password: &str,
|
||||
) -> Result<domain::value_objects::PasswordHash, DomainError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain_password.as_bytes(), &salt)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.to_string();
|
||||
domain::value_objects::PasswordHash::new(hash)
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
&self,
|
||||
plain_password: &str,
|
||||
hash: &domain::value_objects::PasswordHash,
|
||||
) -> Result<bool, DomainError> {
|
||||
let parsed = PasswordHash::new(hash.value())
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain_password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
secret: &str,
|
||||
ttl_seconds: u64,
|
||||
) -> (
|
||||
Arc<dyn domain::ports::AuthService>,
|
||||
Arc<dyn domain::ports::PasswordHasher>,
|
||||
) {
|
||||
(
|
||||
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
|
||||
Arc::new(Argon2PasswordHasher),
|
||||
)
|
||||
}
|
||||
@@ -8,4 +8,5 @@ sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
domain = { workspace = true }
|
||||
|
||||
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);
|
||||
@@ -1,5 +1,7 @@
|
||||
mod refresh_sessions;
|
||||
pub mod repository;
|
||||
mod row;
|
||||
mod search;
|
||||
mod users;
|
||||
|
||||
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||
|
||||
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::RefreshSession;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::repository::SqliteSongRepository;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RefreshSessionRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
token: String,
|
||||
expires_at: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
fn row_to_session(row: RefreshSessionRow) -> Result<RefreshSession, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&row.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let user_id = uuid::Uuid::parse_str(&row.user_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.to_utc();
|
||||
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.to_utc();
|
||||
|
||||
Ok(RefreshSession {
|
||||
id,
|
||||
user_id: UserId::from_uuid(user_id),
|
||||
token: row.token,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::RefreshSessionRepository for SqliteSongRepository {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session.id.to_string())
|
||||
.bind(session.user_id.value().to_string())
|
||||
.bind(&session.token)
|
||||
.bind(session.expires_at.to_rfc3339())
|
||||
.bind(session.created_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
row.map(row_to_session).transpose()
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
81
crates/adapters/sqlite/src/users.rs
Normal file
81
crates/adapters/sqlite/src/users.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::User;
|
||||
use domain::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
|
||||
use crate::repository::SqliteSongRepository;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UserRow {
|
||||
id: String,
|
||||
email: String,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
}
|
||||
|
||||
fn row_to_user(row: UserRow) -> Result<User, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&row.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(User::from_persistence(
|
||||
UserId::from_uuid(id),
|
||||
Email::new(&row.email)?,
|
||||
Username::new(&row.username)?,
|
||||
PasswordHash::new(row.password_hash)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::UserRepository for SqliteSongRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
|
||||
)
|
||||
.bind(email.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
row.map(row_to_user).transpose()
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
|
||||
)
|
||||
.bind(username.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
row.map(row_to_user).transpose()
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
row.map(row_to_user).transpose()
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(user.id().value().to_string())
|
||||
.bind(user.email().value())
|
||||
.bind(user.username().value())
|
||||
.bind(user.password_hash().value())
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user