14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
71
crates/adapters/auth/src/jwt_service.rs
Normal file
71
crates/adapters/auth/src/jwt_service.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||
|
||||
use config::AuthConfig;
|
||||
use domain::auth::GeneratedToken;
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub struct JwtAuthService {
|
||||
secret: String,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(config: &AuthConfig) -> Result<Self, DomainError> {
|
||||
let secret = config
|
||||
.jwt_secret
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| DomainError::InvalidInput("JWT secret must be configured".into()))?;
|
||||
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds: config.access_token_ttl_seconds as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::AuthServicePort 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 u64,
|
||||
};
|
||||
|
||||
let token = jsonwebtoken::encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to generate token: {e}")))?;
|
||||
|
||||
Ok(GeneratedToken::new(token, expires_at))
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
let data = jsonwebtoken::decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
|
||||
|
||||
let uuid: uuid::Uuid = data
|
||||
.claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
|
||||
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
5
crates/adapters/auth/src/lib.rs
Normal file
5
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod jwt_service;
|
||||
mod password_hasher;
|
||||
|
||||
pub use jwt_service::JwtAuthService;
|
||||
pub use password_hasher::Argon2PasswordHasher;
|
||||
33
crates/adapters/auth/src/password_hasher.rs
Normal file
33
crates/adapters/auth/src/password_hasher.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub struct Argon2PasswordHasher;
|
||||
|
||||
impl domain::ports::PasswordHasherPort for Argon2PasswordHasher {
|
||||
fn hash(&self, raw_password: &str) -> Result<domain::user::PasswordHash, DomainError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let hash = Argon2::default()
|
||||
.hash_password(raw_password.as_bytes(), &salt)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to hash password: {e}")))?
|
||||
.to_string();
|
||||
|
||||
Ok(domain::user::PasswordHash::new(hash))
|
||||
}
|
||||
|
||||
fn verify(
|
||||
&self,
|
||||
raw_password: &str,
|
||||
hash: &domain::user::PasswordHash,
|
||||
) -> Result<bool, DomainError> {
|
||||
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid password hash: {e}")))?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(raw_password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user