feat(auth): JWT AuthService and Argon2 PasswordHasher
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user