diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs index cd542330..c86b8b7e 100644 --- a/src/application/services/app_password_service.rs +++ b/src/application/services/app_password_service.rs @@ -10,18 +10,51 @@ use crate::application::ports::auth_ports::{ use crate::common::errors::DomainError; use crate::domain::entities::app_password::AppPassword; use chrono::{Duration, Utc}; +use moka::future::Cache; use std::sync::Arc; +use std::time::Duration as StdDuration; /// App password token length (32 random alphanumeric chars after prefix). const TOKEN_LENGTH: usize = 32; /// Prefix for all app password tokens (makes them easily identifiable). const TOKEN_PREFIX: &str = "oxicloud-"; +/// TTL for cached Basic Auth verification results. +/// Balances performance (avoids repeated Argon2id + DB queries) with security +/// (limits the window during which a revoked app password remains usable). +const BASIC_AUTH_CACHE_TTL_SECS: u64 = 30; + +/// Maximum number of cached Basic Auth verifications. +/// Each entry is ~160 bytes (32-byte key + 4 small strings), so 10 000 +/// entries ≈ 1.6 MB — negligible compared to other in-memory caches. +const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000; + +/// Cached identity returned after a successful Basic Auth verification. +#[derive(Clone)] +struct CachedBasicAuthResult { + user_id: String, + username: String, + email: String, + role: String, +} + pub struct AppPasswordService { repo: Arc, hasher: Arc, user_repo: Arc, base_url: String, + + /// In-memory cache of successful Basic Auth verifications. + /// + /// **Key**: `blake3(username + ":" + password)` — the plain-text password + /// is never stored; only a cryptographic hash is kept as lookup key. + /// + /// **Value**: the authenticated identity (user_id, username, email, role). + /// + /// **Eviction**: TTL-based (30 s) + capacity-based (10 000 entries). + /// Failed verifications are *never* cached, so brute-force attackers + /// always pay the full Argon2id cost. + auth_cache: Cache<[u8; 32], CachedBasicAuthResult>, } impl AppPasswordService { @@ -31,11 +64,23 @@ impl AppPasswordService { user_repo: Arc, base_url: String, ) -> Self { + let auth_cache = Cache::builder() + .max_capacity(BASIC_AUTH_CACHE_MAX_ENTRIES) + .time_to_live(StdDuration::from_secs(BASIC_AUTH_CACHE_TTL_SECS)) + .build(); + + tracing::info!( + "AppPasswordService Basic Auth cache initialized: TTL={}s, max={} entries", + BASIC_AUTH_CACHE_TTL_SECS, + BASIC_AUTH_CACHE_MAX_ENTRIES, + ); + Self { repo, hasher, user_repo, base_url, + auth_cache, } } @@ -176,6 +221,10 @@ impl AppPasswordService { } /// Revoke (soft-delete) an app password. Verifies ownership. + /// + /// Also invalidates **all** cached Basic Auth entries for the owning user + /// so that the revocation takes effect immediately (instead of waiting + /// up to `BASIC_AUTH_CACHE_TTL_SECS`). pub async fn revoke(&self, user_id: &str, id: &str) -> Result { let ap = self.repo.get_by_id(id).await?; if ap.user_id != user_id { @@ -184,6 +233,16 @@ impl AppPasswordService { )); } self.repo.revoke(id).await?; + + // Invalidate all cached auth entries for this user so the + // revocation is effective immediately. + let uid = user_id.to_string(); + self.auth_cache + .invalidate_entries_if(move |_key, val| val.user_id == uid) + .ok(); + + tracing::debug!("Revoked app password {} — auth cache entries for user {} invalidated", id, user_id); + Ok(AppPasswordRevokeResponseDto { status: "revoked".to_string(), id: id.to_string(), @@ -193,11 +252,40 @@ impl AppPasswordService { /// Verify username + app password for HTTP Basic Auth. /// /// Returns `(user_id, username, email, role)` on success. + /// + /// ## Performance + /// + /// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS` + /// (default 30 s) keyed by `blake3(username:password)`. This avoids + /// the expensive Argon2id computation **and** the three PostgreSQL + /// round-trips on every repeated DAV request from the same client. + /// + /// Failed verifications are **never** cached, preserving the full + /// Argon2id cost as a brute-force deterrent. pub async fn verify_basic_auth( &self, username: &str, password: &str, ) -> Result<(String, String, String, String), DomainError> { + // ── 1. Compute cache key = blake3("username:password") ──────── + // The plain-text password is never stored; only the 32-byte + // cryptographic digest is used as lookup key. + let cache_key: [u8; 32] = blake3::hash( + format!("{}:{}", username, password).as_bytes(), + ) + .into(); + + // ── 2. Cache hit → return immediately ──────────────────────── + if let Some(cached) = self.auth_cache.get(&cache_key).await { + return Ok(( + cached.user_id, + cached.username, + cached.email, + cached.role, + )); + } + + // ── 3. Cache miss → full verification ──────────────────────── // Look up user by username let user = self .user_repo @@ -217,21 +305,33 @@ impl AppPasswordService { )); } - // Try each app password hash + // Try each app password hash (Argon2id — CPU-intensive) for ap in &app_passwords { if let Ok(true) = self.hasher.verify_password(password, &ap.password_hash).await { // Update last_used_at (fire-and-forget; don't fail auth on touch error) let _ = self.repo.touch_last_used(&ap.id).await; + let result = CachedBasicAuthResult { + user_id: user.id().to_string(), + username: user.username().to_string(), + email: user.email().to_string(), + role: user.role().to_string(), + }; + + // ── 4. Cache the successful result ──────────────────── + self.auth_cache.insert(cache_key, result.clone()).await; + return Ok(( - user.id().to_string(), - user.username().to_string(), - user.email().to_string(), - user.role().to_string(), + result.user_id, + result.username, + result.email, + result.role, )); } } + // Failed verifications are intentionally NOT cached so that + // brute-force attackers always pay the full Argon2id cost. Err(DomainError::unauthorized( "Invalid username or app password", ))