diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index cf9d311..136ce83 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -10,5 +10,7 @@ thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tokio = { workspace = true } +serde = { workspace = true } jsonwebtoken = "9" argon2 = "0.5" +rand = "0.8" diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index e69de29..53088b9 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + value_objects::{PasswordHash, UserId}, +}; + +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, +} + +pub struct JwtAuthService { + secret: String, + ttl_seconds: i64, +} + +impl JwtAuthService { + pub fn new(secret: String, ttl_seconds: i64) -> Self { + Self { secret, ttl_seconds } + } +} + +impl AuthService for JwtAuthService { + fn generate_token(&self, user_id: &UserId) -> Result { + let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize; + let claims = Claims { + sub: user_id.as_uuid().to_string(), + exp, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.secret.as_bytes()), + ) + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(GeneratedToken { + token, + user_id: user_id.clone(), + }) + } + + fn validate_token(&self, token: &str) -> Result { + let data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| DomainError::Unauthorized)?; + let uuid = uuid::Uuid::parse_str(&data.claims.sub) + .map_err(|_| DomainError::Unauthorized)?; + Ok(UserId::from_uuid(uuid)) + } +} + +pub struct Argon2PasswordHasher; + +#[async_trait] +impl PasswordHasher for Argon2PasswordHasher { + async fn hash(&self, plain: &str) -> Result { + use argon2::{ + password_hash::SaltString, + Argon2, PasswordHasher as _, + }; + use rand::rngs::OsRng; + let salt = SaltString::generate(OsRng); + let hash = Argon2::default() + .hash_password(plain.as_bytes(), &salt) + .map_err(|e| DomainError::Internal(e.to_string()))? + .to_string(); + Ok(PasswordHash(hash)) + } + + async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { + use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; + let parsed = ArgonHash::new(&hash.0) + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(Argon2::default() + .verify_password(plain.as_bytes(), &parsed) + .is_ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::AuthService; + + #[test] + fn generate_and_validate_token() { + let svc = JwtAuthService::new("secret".into(), 3600); + let id = UserId::new(); + let tok = svc.generate_token(&id).unwrap(); + let parsed = svc.validate_token(&tok.token).unwrap(); + assert_eq!(parsed.as_uuid(), id.as_uuid()); + } + + #[test] + fn invalid_token_returns_unauthorized() { + let svc = JwtAuthService::new("secret".into(), 3600); + let err = svc.validate_token("not.a.token").unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized)); + } + + #[tokio::test] + async fn hash_and_verify() { + let hasher = Argon2PasswordHasher; + let hash = hasher.hash("mypassword").await.unwrap(); + assert!(hasher.verify("mypassword", &hash).await.unwrap()); + assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); + } +}