feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
19
crates/adapters/auth/Cargo.toml
Normal file
19
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
bcrypt = "0.15"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
89
crates/adapters/auth/src/api_key_service.rs
Normal file
89
crates/adapters/auth/src/api_key_service.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ApiKeyRepository, ApiKeyService},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ApiKeyServiceImpl {
|
||||
repo: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
impl ApiKeyServiceImpl {
|
||||
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
fn hash(raw: &str) -> String {
|
||||
hex::encode(Sha256::digest(raw.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyService for ApiKeyServiceImpl {
|
||||
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
|
||||
let hash = Self::hash(raw_key);
|
||||
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for FakeApiKeyRepo {
|
||||
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push(key.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
|
||||
}
|
||||
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_known_key_returns_user_id() {
|
||||
let uid = UserId::new();
|
||||
let raw = "super-secret-key";
|
||||
let hash = ApiKeyServiceImpl::hash(raw);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: uid.clone(),
|
||||
key_hash: hash,
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key(raw).await.unwrap();
|
||||
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_unknown_key_returns_none() {
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key("unknown-key").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
123
crates/adapters/auth/src/lib.rs
Normal file
123
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
mod api_key_service;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api_key_service::ApiKeyServiceImpl;
|
||||
|
||||
#[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> {
|
||||
if hash.0.starts_with("$2") {
|
||||
return bcrypt::verify(plain, &hash.0)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()));
|
||||
}
|
||||
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("a-secret-that-is-at-least-32-bytes!!".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("a-secret-that-is-at-least-32-bytes!!".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