feat(auth): JWT AuthService and Argon2 PasswordHasher

This commit is contained in:
2026-05-14 03:47:08 +02:00
parent 69608cfc75
commit 2b428b2b0a
2 changed files with 118 additions and 0 deletions

View File

@@ -10,5 +10,7 @@ thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true }
jsonwebtoken = "9" jsonwebtoken = "9"
argon2 = "0.5" argon2 = "0.5"
rand = "0.8"

View File

@@ -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<GeneratedToken, DomainError> {
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<UserId, DomainError> {
let data = decode::<Claims>(
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<PasswordHash, DomainError> {
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<bool, DomainError> {
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());
}
}