From efcf88c4d73f8ab06c4a596d60418ccf5d95bb27 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 3 Mar 2026 01:49:18 +0100 Subject: [PATCH] style: cargo fmt --all --- src/application/adapters/caldav_adapter.rs | 44 +- .../adapters/caldav_adapter_test.rs | 24 +- .../services/app_password_service.rs | 673 +++++++------- src/application/services/batch_operations.rs | 12 +- .../services/device_auth_service.rs | 872 +++++++++--------- src/application/services/folder_service.rs | 32 +- src/application/services/mod.rs | 2 +- src/common/config.rs | 3 +- src/common/di.rs | 17 +- src/domain/entities/device_code.rs | 529 ++++++----- src/infrastructure/repositories/mod.rs | 5 +- .../pg/app_password_pg_repository.rs | 366 ++++---- .../pg/device_code_pg_repository.rs | 520 ++++++----- .../repositories/pg/folder_db_repository.rs | 107 ++- src/infrastructure/services/jwt_service.rs | 4 +- .../services/login_lockout_service.rs | 303 +++--- src/infrastructure/services/mod.rs | 2 +- .../services/path_resolver_service.rs | 424 +++++---- src/interfaces/api/cookie_auth.rs | 288 +++--- src/interfaces/api/handlers/auth_handler.rs | 15 +- src/interfaces/api/handlers/caldav_handler.rs | 78 +- .../api/handlers/carddav_handler.rs | 4 +- .../api/handlers/device_auth_handler.rs | 452 +++++---- src/interfaces/api/handlers/mod.rs | 2 +- src/interfaces/api/handlers/webdav_handler.rs | 108 ++- src/interfaces/middleware/auth.rs | 14 +- src/interfaces/middleware/csrf.rs | 148 ++- src/interfaces/middleware/rate_limit.rs | 398 ++++---- src/main.rs | 40 +- 29 files changed, 2754 insertions(+), 2732 deletions(-) diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index ef740fd9..fd6cfdb2 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -351,27 +351,23 @@ impl CalDavAdapter { username ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - xml_writer - .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; // calendar-home-set - xml_writer - .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "/caldav/{}/", username ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - xml_writer - .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; } PropFindType::PropName => { xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; xml_writer .write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?; - xml_writer - .write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?; } PropFindType::Prop(props) => { Self::write_root_requested_props(xml_writer, username, props)?; @@ -404,9 +400,8 @@ impl CalDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; } ("DAV:", "current-user-principal") => { - xml_writer.write_event(Event::Start(BytesStart::new( - "D:current-user-principal", - )))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:current-user-principal")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "/caldav/principals/{}/", @@ -417,16 +412,14 @@ impl CalDavAdapter { .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; } ("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => { - xml_writer - .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "/caldav/{}/", username ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - xml_writer - .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; } ("DAV:", "displayname") => { xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; @@ -452,10 +445,7 @@ impl CalDavAdapter { } /// Write standard properties for a principal resource. - fn write_principal_props( - xml_writer: &mut Writer, - username: &str, - ) -> Result<()> { + fn write_principal_props(xml_writer: &mut Writer, username: &str) -> Result<()> { // resourcetype — principal xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; @@ -510,9 +500,8 @@ impl CalDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; } ("DAV:", "current-user-principal") => { - xml_writer.write_event(Event::Start(BytesStart::new( - "D:current-user-principal", - )))?; + xml_writer + .write_event(Event::Start(BytesStart::new("D:current-user-principal")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "/caldav/principals/{}/", @@ -523,16 +512,14 @@ impl CalDavAdapter { .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; } ("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => { - xml_writer - .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "/caldav/{}/", username ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - xml_writer - .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; } ("urn:ietf:params:xml:ns:caldav", "calendar-user-address-set") => { xml_writer.write_event(Event::Start(BytesStart::new( @@ -544,9 +531,8 @@ impl CalDavAdapter { username ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; - xml_writer.write_event(Event::End(BytesEnd::new( - "C:calendar-user-address-set", - )))?; + xml_writer + .write_event(Event::End(BytesEnd::new("C:calendar-user-address-set")))?; } _ => { let prop_name = if prop.namespace == "http://calendarserver.org/ns/" { diff --git a/src/application/adapters/caldav_adapter_test.rs b/src/application/adapters/caldav_adapter_test.rs index 50ab323d..75e21e3f 100644 --- a/src/application/adapters/caldav_adapter_test.rs +++ b/src/application/adapters/caldav_adapter_test.rs @@ -356,7 +356,11 @@ mod tests { "#; let result = WebDavAdapter::parse_propfind(Cursor::new(xml)); - assert!(result.is_ok(), "Failed to parse PROPFIND: {:?}", result.err()); + assert!( + result.is_ok(), + "Failed to parse PROPFIND: {:?}", + result.err() + ); let request = result.unwrap(); match request.prop_find_type { @@ -431,7 +435,11 @@ mod tests { "/caldav/", "testuser", ); - assert!(result.is_ok(), "Failed to generate root propfind: {:?}", result.err()); + assert!( + result.is_ok(), + "Failed to generate root propfind: {:?}", + result.err() + ); let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); @@ -511,11 +519,8 @@ mod tests { }; let mut output = Vec::new(); - let result = CalDavAdapter::generate_principal_propfind_response( - &mut output, - &request, - "testuser", - ); + let result = + CalDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser"); assert!(result.is_ok(), "Failed: {:?}", result.err()); let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); @@ -576,10 +581,7 @@ mod tests { ); // displayname should be populated - assert!( - xml_str.contains("Personal"), - "Should contain calendar name" - ); + assert!(xml_str.contains("Personal"), "Should contain calendar name"); // supported-calendar-component-set should have VEVENT assert!( diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs index c86b8b7e..3f2a07ae 100644 --- a/src/application/services/app_password_service.rs +++ b/src/application/services/app_password_service.rs @@ -1,339 +1,334 @@ -//! App Password application service. -//! -//! Orchestrates creation, verification, listing, and revocation of -//! application-specific passwords for DAV clients. - -use crate::application::dtos::app_password_dto::*; -use crate::application::ports::auth_ports::{ - AppPasswordStoragePort, PasswordHasherPort, UserStoragePort, -}; -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 { - pub fn new( - repo: Arc, - hasher: Arc, - 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, - } - } - - /// Generate a random app password token using cryptographic RNG. - fn generate_token() -> String { - use rand_core::{OsRng, RngCore}; - - let charset: &[u8] = b"abcdefghijklmnopqrstuvwxyz\ - ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - 0123456789"; - let mut rng_bytes = [0u8; TOKEN_LENGTH]; - OsRng.fill_bytes(&mut rng_bytes); - - let random_part: String = rng_bytes - .iter() - .map(|&b| { - let idx = (b as usize) % charset.len(); - charset[idx] as char - }) - .collect(); - format!("{}{}", TOKEN_PREFIX, random_part) - } - - /// Create a new app password for the given user. - /// - /// Returns the response DTO that includes the plain-text password (shown only once). - pub async fn create( - &self, - user_id: &str, - request: CreateAppPasswordRequestDto, - ) -> Result { - // Validate label - let label = request.label.trim().to_string(); - if label.is_empty() || label.len() > 255 { - return Err(DomainError::validation_error( - "Label must be 1-255 characters", - )); - } - - // Fetch user for the username (needed for Basic Auth instructions) - let user = self.user_repo.get_user_by_id(user_id).await?; - let username = user.username().to_string(); - - // Generate the plain-text token - let plain_token = Self::generate_token(); - let prefix = plain_token[..TOKEN_PREFIX.len() + 8].to_string(); - - // Hash the token for storage - let password_hash = self.hasher.hash_password(&plain_token).await?; - - // Calculate expiration - let expires_at = request.expires_in_days.map(|days| { - Utc::now() + Duration::days(days as i64) - }); - - // Create entity - let app_password = AppPassword::new( - user_id.to_string(), - label.clone(), - password_hash, - prefix.clone(), - request.scopes.clone(), - expires_at, - ); - - let saved = self.repo.create(app_password).await?; - - let expires_str = saved - .expires_at - .map(|dt| dt.to_rfc3339()); - - let curl_example = format!( - "curl -u '{}:{}' -X PROPFIND {}/webdav/", - username, plain_token, self.base_url - ); - - Ok(AppPasswordCreatedResponseDto { - id: saved.id, - label, - password: plain_token, - username: username.clone(), - scopes: request.scopes, - expires_at: expires_str, - instructions: AppPasswordInstructions { - davx5: format!( - "In DAVx⁵, add account with base URL: {}/webdav/\n\ - Username: {}\n\ - Password: (the token shown above)", - self.base_url, username - ), - thunderbird: format!( - "In Thunderbird CalDAV/CardDAV:\n\ - URL: {}/caldav/ or {}/carddav/\n\ - Username: {}\n\ - Password: (the token shown above)", - self.base_url, self.base_url, username - ), - rclone: format!( - "rclone config:\n\ - type = webdav\n\ - url = {}/webdav/\n\ - vendor = other\n\ - user = {}\n\ - pass = (the token shown above, use 'rclone obscure' to encode)", - self.base_url, username - ), - curl_example, - }, - }) - } - - /// List all app passwords for a user (excludes plain-text passwords). - pub async fn list(&self, user_id: &str) -> Result { - let passwords = self.repo.list_by_user(user_id).await?; - let total = passwords.len(); - - let app_passwords = passwords - .into_iter() - .map(|ap| { - let is_active = ap.active && !ap.is_expired(); - AppPasswordSummaryDto { - id: ap.id, - label: ap.label, - prefix: format!("{}...", ap.prefix), - scopes: ap.scopes, - created_at: ap.created_at.to_rfc3339(), - last_used_at: ap.last_used_at.map(|dt| dt.to_rfc3339()), - expires_at: ap.expires_at.map(|dt| dt.to_rfc3339()), - active: is_active, - } - }) - .collect(); - - Ok(AppPasswordListResponseDto { - app_passwords, - total, - }) - } - - /// 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 { - return Err(DomainError::unauthorized( - "You can only revoke your own app passwords", - )); - } - 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(), - }) - } - - /// 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 - .get_user_by_username(username) - .await - .map_err(|_| DomainError::unauthorized("Invalid username or app password"))?; - - // Get all active app passwords for this user - let app_passwords = self - .repo - .get_active_by_user_id(user.id()) - .await?; - - if app_passwords.is_empty() { - return Err(DomainError::unauthorized( - "Invalid username or app password", - )); - } - - // 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(( - 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", - )) - } -} +//! App Password application service. +//! +//! Orchestrates creation, verification, listing, and revocation of +//! application-specific passwords for DAV clients. + +use crate::application::dtos::app_password_dto::*; +use crate::application::ports::auth_ports::{ + AppPasswordStoragePort, PasswordHasherPort, UserStoragePort, +}; +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 { + pub fn new( + repo: Arc, + hasher: Arc, + 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, + } + } + + /// Generate a random app password token using cryptographic RNG. + fn generate_token() -> String { + use rand_core::{OsRng, RngCore}; + + let charset: &[u8] = b"abcdefghijklmnopqrstuvwxyz\ + ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + 0123456789"; + let mut rng_bytes = [0u8; TOKEN_LENGTH]; + OsRng.fill_bytes(&mut rng_bytes); + + let random_part: String = rng_bytes + .iter() + .map(|&b| { + let idx = (b as usize) % charset.len(); + charset[idx] as char + }) + .collect(); + format!("{}{}", TOKEN_PREFIX, random_part) + } + + /// Create a new app password for the given user. + /// + /// Returns the response DTO that includes the plain-text password (shown only once). + pub async fn create( + &self, + user_id: &str, + request: CreateAppPasswordRequestDto, + ) -> Result { + // Validate label + let label = request.label.trim().to_string(); + if label.is_empty() || label.len() > 255 { + return Err(DomainError::validation_error( + "Label must be 1-255 characters", + )); + } + + // Fetch user for the username (needed for Basic Auth instructions) + let user = self.user_repo.get_user_by_id(user_id).await?; + let username = user.username().to_string(); + + // Generate the plain-text token + let plain_token = Self::generate_token(); + let prefix = plain_token[..TOKEN_PREFIX.len() + 8].to_string(); + + // Hash the token for storage + let password_hash = self.hasher.hash_password(&plain_token).await?; + + // Calculate expiration + let expires_at = request + .expires_in_days + .map(|days| Utc::now() + Duration::days(days as i64)); + + // Create entity + let app_password = AppPassword::new( + user_id.to_string(), + label.clone(), + password_hash, + prefix.clone(), + request.scopes.clone(), + expires_at, + ); + + let saved = self.repo.create(app_password).await?; + + let expires_str = saved.expires_at.map(|dt| dt.to_rfc3339()); + + let curl_example = format!( + "curl -u '{}:{}' -X PROPFIND {}/webdav/", + username, plain_token, self.base_url + ); + + Ok(AppPasswordCreatedResponseDto { + id: saved.id, + label, + password: plain_token, + username: username.clone(), + scopes: request.scopes, + expires_at: expires_str, + instructions: AppPasswordInstructions { + davx5: format!( + "In DAVx⁵, add account with base URL: {}/webdav/\n\ + Username: {}\n\ + Password: (the token shown above)", + self.base_url, username + ), + thunderbird: format!( + "In Thunderbird CalDAV/CardDAV:\n\ + URL: {}/caldav/ or {}/carddav/\n\ + Username: {}\n\ + Password: (the token shown above)", + self.base_url, self.base_url, username + ), + rclone: format!( + "rclone config:\n\ + type = webdav\n\ + url = {}/webdav/\n\ + vendor = other\n\ + user = {}\n\ + pass = (the token shown above, use 'rclone obscure' to encode)", + self.base_url, username + ), + curl_example, + }, + }) + } + + /// List all app passwords for a user (excludes plain-text passwords). + pub async fn list(&self, user_id: &str) -> Result { + let passwords = self.repo.list_by_user(user_id).await?; + let total = passwords.len(); + + let app_passwords = passwords + .into_iter() + .map(|ap| { + let is_active = ap.active && !ap.is_expired(); + AppPasswordSummaryDto { + id: ap.id, + label: ap.label, + prefix: format!("{}...", ap.prefix), + scopes: ap.scopes, + created_at: ap.created_at.to_rfc3339(), + last_used_at: ap.last_used_at.map(|dt| dt.to_rfc3339()), + expires_at: ap.expires_at.map(|dt| dt.to_rfc3339()), + active: is_active, + } + }) + .collect(); + + Ok(AppPasswordListResponseDto { + app_passwords, + total, + }) + } + + /// 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 { + return Err(DomainError::unauthorized( + "You can only revoke your own app passwords", + )); + } + 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(), + }) + } + + /// 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 + .get_user_by_username(username) + .await + .map_err(|_| DomainError::unauthorized("Invalid username or app password"))?; + + // Get all active app passwords for this user + let app_passwords = self.repo.get_active_by_user_id(user.id()).await?; + + if app_passwords.is_empty() { + return Err(DomainError::unauthorized( + "Invalid username or app password", + )); + } + + // 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((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", + )) + } +} diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 571e2cb0..91ceac45 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -138,7 +138,9 @@ impl BatchOperationService { let target_folder = target_folder.clone(); async move { - let copy_result = mgmt.copy_file(&file_id, target_folder.map(|s| s.to_string())).await; + let copy_result = mgmt + .copy_file(&file_id, target_folder.map(|s| s.to_string())) + .await; (file_id, copy_result) } })) @@ -201,7 +203,9 @@ impl BatchOperationService { let target_folder = target_folder.clone(); async move { - let move_result = mgmt.move_file(&file_id, target_folder.map(|s| s.to_string())).await; + let move_result = mgmt + .move_file(&file_id, target_folder.map(|s| s.to_string())) + .await; (file_id, move_result) } })) @@ -578,7 +582,9 @@ impl BatchOperationService { let caller = caller.clone(); async move { - let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()) }; + let dto = MoveFolderDto { + parent_id: target.map(|s| s.to_string()), + }; let move_result = folder_service.move_folder(&folder_id, dto, &caller).await; (folder_id, move_result) } diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index 8981216a..e3e42623 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -1,437 +1,435 @@ -//! OAuth 2.0 Device Authorization Grant service (RFC 8628). -//! -//! Orchestrates the full device flow: -//! 1. `initiate` — generates device_code + user_code, stores in DB -//! 2. `verify_user_code` — looks up pending code for the verification page -//! 3. `approve` — user approves, tokens are generated and stored -//! 4. `deny` — user denies the request -//! 5. `poll` — client polls by device_code; returns tokens or status error -//! 6. `cleanup_expired` — background job to purge stale entries - -use std::sync::Arc; - -use crate::application::dtos::device_auth_dto::*; -use crate::application::ports::auth_ports::{DeviceCodeStoragePort, TokenServicePort, UserStoragePort}; -use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; -use crate::domain::entities::session::Session; -use crate::application::ports::auth_ports::SessionStoragePort; - -/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min). -const DEVICE_CODE_LIFETIME_SECS: i64 = 900; - -/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s). -const DEFAULT_POLL_INTERVAL: i32 = 5; - -/// Length of the device_code (hex-encoded, 64 chars = 32 bytes). -const DEVICE_CODE_BYTES: usize = 32; - -/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234" -/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos. -const USER_CODE_LETTER_LEN: usize = 4; -const USER_CODE_DIGIT_LEN: usize = 4; - -pub struct DeviceAuthService { - device_code_storage: Arc, - token_service: Arc, - user_storage: Arc, - session_storage: Arc, - /// Base URL of the server (e.g. "https://cloud.example.com") - base_url: String, -} - -impl DeviceAuthService { - pub fn new( - device_code_storage: Arc, - token_service: Arc, - user_storage: Arc, - session_storage: Arc, - base_url: String, - ) -> Self { - Self { - device_code_storage, - token_service, - user_storage, - session_storage, - base_url, - } - } - - // ======================================================================== - // 1. Initiate — called by the DAV client - // ======================================================================== - - /// Start a new device authorization flow. - /// - /// Returns the response that the client displays to the user. - pub async fn initiate( - &self, - req: DeviceAuthorizeRequestDto, - ) -> Result { - let device_code_token = generate_device_code(); - let user_code = generate_user_code(); - - let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/')); - let verification_uri_complete = format!("{}?code={}", verification_uri, user_code); - - let dc = DeviceCode::new( - device_code_token.clone(), - user_code.clone(), - req.client_name, - req.scope, - verification_uri.clone(), - Some(verification_uri_complete.clone()), - DEVICE_CODE_LIFETIME_SECS, - DEFAULT_POLL_INTERVAL, - ); - - let dc = self.device_code_storage.create_device_code(dc).await?; - - tracing::info!( - "Device auth flow initiated: user_code={}, expires_in={}s", - user_code, - DEVICE_CODE_LIFETIME_SECS - ); - - Ok(DeviceAuthorizeResponseDto { - device_code: device_code_token, - user_code, - verification_uri, - verification_uri_complete: Some(verification_uri_complete), - expires_in: dc.seconds_remaining(), - interval: DEFAULT_POLL_INTERVAL, - }) - } - - // ======================================================================== - // 2. Verify — user opens the verification page, looks up pending code - // ======================================================================== - - /// Look up a pending device code by user_code for the verification page. - pub async fn verify_user_code( - &self, - user_code: &str, - ) -> Result { - let normalized = user_code.trim().to_uppercase().replace(' ', ""); - - match self - .device_code_storage - .get_pending_by_user_code(&normalized) - .await - { - Ok(dc) => { - if dc.is_expired() { - return Ok(DeviceVerifyInfoDto { - client_name: dc.client_name().to_string(), - scopes: dc.scopes().to_string(), - valid: false, - }); - } - Ok(DeviceVerifyInfoDto { - client_name: dc.client_name().to_string(), - scopes: dc.scopes().to_string(), - valid: true, - }) - } - Err(_) => Ok(DeviceVerifyInfoDto { - client_name: String::new(), - scopes: String::new(), - valid: false, - }), - } - } - - // ======================================================================== - // 3. Approve — authenticated user approves the device code - // ======================================================================== - - /// Approve a device code, generating tokens for the polling client. - /// - /// * `user_code` — the code from the verification page - /// * `user_id` — the authenticated user's ID (from session/JWT) - pub async fn approve( - &self, - user_code: &str, - user_id: &str, - ) -> Result<(), DomainError> { - let normalized = user_code.trim().to_uppercase().replace(' ', ""); - - let mut dc = self - .device_code_storage - .get_pending_by_user_code(&normalized) - .await?; - - if dc.is_expired() { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "DeviceCode", - "Device code has expired. Please start a new authorization flow.", - )); - } - - // Fetch user to generate tokens - let user = self.user_storage.get_user_by_id(user_id).await?; - - // Generate internal JWT access token + refresh token - let access_token = self.token_service.generate_access_token(&user)?; - let refresh_token = self.token_service.generate_refresh_token(); - - // Persist refresh token as a session - let session = Session::new( - user_id.to_string(), - refresh_token.clone(), - None, // ip_address - Some(format!("device:{}", dc.client_name())), // user_agent - self.token_service.refresh_token_expiry_days(), - ); - self.session_storage.create_session(session).await?; - - // Store tokens on the device code entity - dc.authorize(user_id.to_string(), access_token, refresh_token); - self.device_code_storage.update_device_code(dc).await?; - - tracing::info!( - "Device code approved by user {} (user_code={})", - user_id, - normalized - ); - - Ok(()) - } - - // ======================================================================== - // 4. Deny — authenticated user denies the device code - // ======================================================================== - - pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> { - let normalized = user_code.trim().to_uppercase().replace(' ', ""); - - let mut dc = self - .device_code_storage - .get_pending_by_user_code(&normalized) - .await?; - - dc.deny(); - self.device_code_storage.update_device_code(dc).await?; - - tracing::info!("Device code denied (user_code={})", normalized); - - Ok(()) - } - - // ======================================================================== - // 5. Poll — client polls by device_code for tokens - // ======================================================================== - - /// Client polls for tokens. Returns: - /// - `Ok(DeviceTokenSuccessDto)` if authorized - /// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied - pub async fn poll( - &self, - device_code: &str, - ) -> Result { - let mut dc = self - .device_code_storage - .get_by_device_code(device_code) - .await - .map_err(|_| DevicePollError::InvalidDeviceCode)?; - - // Check expiry first - if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending { - let mut expired_dc = dc.clone(); - expired_dc.mark_expired(); - let _ = self.device_code_storage.update_device_code(expired_dc).await; - return Err(DevicePollError::ExpiredToken); - } - - match dc.status() { - DeviceCodeStatus::Pending => { - // Check for slow_down (polling too fast) - if dc.is_polling_too_fast() { - return Err(DevicePollError::SlowDown); - } - // Record this poll - dc.record_poll(); - let _ = self.device_code_storage.update_device_code(dc).await; - Err(DevicePollError::AuthorizationPending) - } - DeviceCodeStatus::Authorized => { - let access_token = dc.access_token().unwrap_or_default().to_string(); - let refresh_token = dc.refresh_token().unwrap_or_default().to_string(); - let scope = dc.scopes().to_string(); - - Ok(DeviceTokenSuccessDto { - access_token, - token_type: "Bearer".to_string(), - refresh_token, - expires_in: self.token_service.refresh_token_expiry_secs(), - scope, - }) - } - DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied), - DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken), - } - } - - // ======================================================================== - // 6. Cleanup — purge expired entries - // ======================================================================== - - pub async fn cleanup_expired(&self) -> Result { - let deleted = self.device_code_storage.delete_expired().await?; - if deleted > 0 { - tracing::info!("Device code cleanup: {} expired entries removed", deleted); - } - Ok(deleted) - } - - // ======================================================================== - // 7. List — user's authorized devices (for UI) - // ======================================================================== - - pub async fn list_user_devices( - &self, - user_id: &str, - ) -> Result, DomainError> { - let codes = self.device_code_storage.list_by_user(user_id).await?; - Ok(codes - .into_iter() - .map(|dc| DeviceInfoDto { - id: dc.id().to_string(), - client_name: dc.client_name().to_string(), - scopes: dc.scopes().to_string(), - status: dc.status().as_str().to_string(), - created_at: dc.created_at().to_rfc3339(), - authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()), - expires_at: dc.expires_at().to_rfc3339(), - }) - .collect()) - } - - // ======================================================================== - // 8. Revoke — user revokes a device authorization - // ======================================================================== - - pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> { - // Verify ownership before deleting - let devices = self.device_code_storage.list_by_user(user_id).await?; - let found = devices.iter().any(|d| d.id() == device_id); - if !found { - return Err(DomainError::new( - ErrorKind::NotFound, - "DeviceCode", - "Device authorization not found or not owned by you", - )); - } - self.device_code_storage.delete_by_id(device_id).await - } -} - -// ============================================================================ -// Poll error (typed for RFC 8628 error responses) -// ============================================================================ - -/// Typed errors for the device token polling endpoint (RFC 8628 §3.5). -#[derive(Debug)] -pub enum DevicePollError { - /// The authorization request is still pending (user hasn't acted yet). - AuthorizationPending, - /// The client is polling too fast; increase the interval. - SlowDown, - /// The user denied the authorization request. - AccessDenied, - /// The device_code has expired. - ExpiredToken, - /// The device_code is not recognized. - InvalidDeviceCode, -} - -impl DevicePollError { - /// RFC 8628 error string for the JSON response. - pub fn error_code(&self) -> &'static str { - match self { - Self::AuthorizationPending => "authorization_pending", - Self::SlowDown => "slow_down", - Self::AccessDenied => "access_denied", - Self::ExpiredToken => "expired_token", - Self::InvalidDeviceCode => "invalid_grant", - } - } - - pub fn description(&self) -> &'static str { - match self { - Self::AuthorizationPending => { - "The authorization request is still pending. Continue polling." - } - Self::SlowDown => "You are polling too frequently. Please slow down.", - Self::AccessDenied => "The user denied the authorization request.", - Self::ExpiredToken => { - "The device_code has expired. Please start a new authorization flow." - } - Self::InvalidDeviceCode => "The device_code is not recognized.", - } - } - - /// HTTP status code per RFC 8628 §3.5: - /// - authorization_pending and slow_down: 400 - /// - access_denied: 403 - /// - expired_token: 400 - pub fn http_status(&self) -> u16 { - match self { - Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400, - Self::AccessDenied => 403, - Self::InvalidDeviceCode => 400, - } - } -} - -// ============================================================================ -// Additional DTOs (used by service, not in the handler module) -// ============================================================================ - -/// DTO for listing authorized devices in the user's profile. -#[derive(Debug, serde::Serialize)] -pub struct DeviceInfoDto { - pub id: String, - pub client_name: String, - pub scopes: String, - pub status: String, - pub created_at: String, - pub authorized_at: Option, - pub expires_at: String, -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/// Generate a cryptographically random device_code (hex-encoded). -fn generate_device_code() -> String { - use rand_core::{OsRng, RngCore}; - let mut bytes = [0u8; DEVICE_CODE_BYTES]; - OsRng.fill_bytes(&mut bytes); - hex::encode(bytes) -} - -/// Generate a human-readable user_code in the format "ABCD-1234". -fn generate_user_code() -> String { - use rand_core::{OsRng, RngCore}; - let mut rng_bytes = [0u8; 8]; - OsRng.fill_bytes(&mut rng_bytes); - - let letters: String = (0..USER_CODE_LETTER_LEN) - .map(|i| { - let b = rng_bytes[i] % 26; - (b'A' + b) as char - }) - .collect(); - - let digits: String = (0..USER_CODE_DIGIT_LEN) - .map(|i| { - let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10; - (b'0' + b) as char - }) - .collect(); - - format!("{}-{}", letters, digits) -} +//! OAuth 2.0 Device Authorization Grant service (RFC 8628). +//! +//! Orchestrates the full device flow: +//! 1. `initiate` — generates device_code + user_code, stores in DB +//! 2. `verify_user_code` — looks up pending code for the verification page +//! 3. `approve` — user approves, tokens are generated and stored +//! 4. `deny` — user denies the request +//! 5. `poll` — client polls by device_code; returns tokens or status error +//! 6. `cleanup_expired` — background job to purge stale entries + +use std::sync::Arc; + +use crate::application::dtos::device_auth_dto::*; +use crate::application::ports::auth_ports::SessionStoragePort; +use crate::application::ports::auth_ports::{ + DeviceCodeStoragePort, TokenServicePort, UserStoragePort, +}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; +use crate::domain::entities::session::Session; + +/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min). +const DEVICE_CODE_LIFETIME_SECS: i64 = 900; + +/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s). +const DEFAULT_POLL_INTERVAL: i32 = 5; + +/// Length of the device_code (hex-encoded, 64 chars = 32 bytes). +const DEVICE_CODE_BYTES: usize = 32; + +/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234" +/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos. +const USER_CODE_LETTER_LEN: usize = 4; +const USER_CODE_DIGIT_LEN: usize = 4; + +pub struct DeviceAuthService { + device_code_storage: Arc, + token_service: Arc, + user_storage: Arc, + session_storage: Arc, + /// Base URL of the server (e.g. "https://cloud.example.com") + base_url: String, +} + +impl DeviceAuthService { + pub fn new( + device_code_storage: Arc, + token_service: Arc, + user_storage: Arc, + session_storage: Arc, + base_url: String, + ) -> Self { + Self { + device_code_storage, + token_service, + user_storage, + session_storage, + base_url, + } + } + + // ======================================================================== + // 1. Initiate — called by the DAV client + // ======================================================================== + + /// Start a new device authorization flow. + /// + /// Returns the response that the client displays to the user. + pub async fn initiate( + &self, + req: DeviceAuthorizeRequestDto, + ) -> Result { + let device_code_token = generate_device_code(); + let user_code = generate_user_code(); + + let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/')); + let verification_uri_complete = format!("{}?code={}", verification_uri, user_code); + + let dc = DeviceCode::new( + device_code_token.clone(), + user_code.clone(), + req.client_name, + req.scope, + verification_uri.clone(), + Some(verification_uri_complete.clone()), + DEVICE_CODE_LIFETIME_SECS, + DEFAULT_POLL_INTERVAL, + ); + + let dc = self.device_code_storage.create_device_code(dc).await?; + + tracing::info!( + "Device auth flow initiated: user_code={}, expires_in={}s", + user_code, + DEVICE_CODE_LIFETIME_SECS + ); + + Ok(DeviceAuthorizeResponseDto { + device_code: device_code_token, + user_code, + verification_uri, + verification_uri_complete: Some(verification_uri_complete), + expires_in: dc.seconds_remaining(), + interval: DEFAULT_POLL_INTERVAL, + }) + } + + // ======================================================================== + // 2. Verify — user opens the verification page, looks up pending code + // ======================================================================== + + /// Look up a pending device code by user_code for the verification page. + pub async fn verify_user_code( + &self, + user_code: &str, + ) -> Result { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + match self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await + { + Ok(dc) => { + if dc.is_expired() { + return Ok(DeviceVerifyInfoDto { + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + valid: false, + }); + } + Ok(DeviceVerifyInfoDto { + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + valid: true, + }) + } + Err(_) => Ok(DeviceVerifyInfoDto { + client_name: String::new(), + scopes: String::new(), + valid: false, + }), + } + } + + // ======================================================================== + // 3. Approve — authenticated user approves the device code + // ======================================================================== + + /// Approve a device code, generating tokens for the polling client. + /// + /// * `user_code` — the code from the verification page + /// * `user_id` — the authenticated user's ID (from session/JWT) + pub async fn approve(&self, user_code: &str, user_id: &str) -> Result<(), DomainError> { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + let mut dc = self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await?; + + if dc.is_expired() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "DeviceCode", + "Device code has expired. Please start a new authorization flow.", + )); + } + + // Fetch user to generate tokens + let user = self.user_storage.get_user_by_id(user_id).await?; + + // Generate internal JWT access token + refresh token + let access_token = self.token_service.generate_access_token(&user)?; + let refresh_token = self.token_service.generate_refresh_token(); + + // Persist refresh token as a session + let session = Session::new( + user_id.to_string(), + refresh_token.clone(), + None, // ip_address + Some(format!("device:{}", dc.client_name())), // user_agent + self.token_service.refresh_token_expiry_days(), + ); + self.session_storage.create_session(session).await?; + + // Store tokens on the device code entity + dc.authorize(user_id.to_string(), access_token, refresh_token); + self.device_code_storage.update_device_code(dc).await?; + + tracing::info!( + "Device code approved by user {} (user_code={})", + user_id, + normalized + ); + + Ok(()) + } + + // ======================================================================== + // 4. Deny — authenticated user denies the device code + // ======================================================================== + + pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + let mut dc = self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await?; + + dc.deny(); + self.device_code_storage.update_device_code(dc).await?; + + tracing::info!("Device code denied (user_code={})", normalized); + + Ok(()) + } + + // ======================================================================== + // 5. Poll — client polls by device_code for tokens + // ======================================================================== + + /// Client polls for tokens. Returns: + /// - `Ok(DeviceTokenSuccessDto)` if authorized + /// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied + pub async fn poll(&self, device_code: &str) -> Result { + let mut dc = self + .device_code_storage + .get_by_device_code(device_code) + .await + .map_err(|_| DevicePollError::InvalidDeviceCode)?; + + // Check expiry first + if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending { + let mut expired_dc = dc.clone(); + expired_dc.mark_expired(); + let _ = self + .device_code_storage + .update_device_code(expired_dc) + .await; + return Err(DevicePollError::ExpiredToken); + } + + match dc.status() { + DeviceCodeStatus::Pending => { + // Check for slow_down (polling too fast) + if dc.is_polling_too_fast() { + return Err(DevicePollError::SlowDown); + } + // Record this poll + dc.record_poll(); + let _ = self.device_code_storage.update_device_code(dc).await; + Err(DevicePollError::AuthorizationPending) + } + DeviceCodeStatus::Authorized => { + let access_token = dc.access_token().unwrap_or_default().to_string(); + let refresh_token = dc.refresh_token().unwrap_or_default().to_string(); + let scope = dc.scopes().to_string(); + + Ok(DeviceTokenSuccessDto { + access_token, + token_type: "Bearer".to_string(), + refresh_token, + expires_in: self.token_service.refresh_token_expiry_secs(), + scope, + }) + } + DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied), + DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken), + } + } + + // ======================================================================== + // 6. Cleanup — purge expired entries + // ======================================================================== + + pub async fn cleanup_expired(&self) -> Result { + let deleted = self.device_code_storage.delete_expired().await?; + if deleted > 0 { + tracing::info!("Device code cleanup: {} expired entries removed", deleted); + } + Ok(deleted) + } + + // ======================================================================== + // 7. List — user's authorized devices (for UI) + // ======================================================================== + + pub async fn list_user_devices( + &self, + user_id: &str, + ) -> Result, DomainError> { + let codes = self.device_code_storage.list_by_user(user_id).await?; + Ok(codes + .into_iter() + .map(|dc| DeviceInfoDto { + id: dc.id().to_string(), + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + status: dc.status().as_str().to_string(), + created_at: dc.created_at().to_rfc3339(), + authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()), + expires_at: dc.expires_at().to_rfc3339(), + }) + .collect()) + } + + // ======================================================================== + // 8. Revoke — user revokes a device authorization + // ======================================================================== + + pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> { + // Verify ownership before deleting + let devices = self.device_code_storage.list_by_user(user_id).await?; + let found = devices.iter().any(|d| d.id() == device_id); + if !found { + return Err(DomainError::new( + ErrorKind::NotFound, + "DeviceCode", + "Device authorization not found or not owned by you", + )); + } + self.device_code_storage.delete_by_id(device_id).await + } +} + +// ============================================================================ +// Poll error (typed for RFC 8628 error responses) +// ============================================================================ + +/// Typed errors for the device token polling endpoint (RFC 8628 §3.5). +#[derive(Debug)] +pub enum DevicePollError { + /// The authorization request is still pending (user hasn't acted yet). + AuthorizationPending, + /// The client is polling too fast; increase the interval. + SlowDown, + /// The user denied the authorization request. + AccessDenied, + /// The device_code has expired. + ExpiredToken, + /// The device_code is not recognized. + InvalidDeviceCode, +} + +impl DevicePollError { + /// RFC 8628 error string for the JSON response. + pub fn error_code(&self) -> &'static str { + match self { + Self::AuthorizationPending => "authorization_pending", + Self::SlowDown => "slow_down", + Self::AccessDenied => "access_denied", + Self::ExpiredToken => "expired_token", + Self::InvalidDeviceCode => "invalid_grant", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::AuthorizationPending => { + "The authorization request is still pending. Continue polling." + } + Self::SlowDown => "You are polling too frequently. Please slow down.", + Self::AccessDenied => "The user denied the authorization request.", + Self::ExpiredToken => { + "The device_code has expired. Please start a new authorization flow." + } + Self::InvalidDeviceCode => "The device_code is not recognized.", + } + } + + /// HTTP status code per RFC 8628 §3.5: + /// - authorization_pending and slow_down: 400 + /// - access_denied: 403 + /// - expired_token: 400 + pub fn http_status(&self) -> u16 { + match self { + Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400, + Self::AccessDenied => 403, + Self::InvalidDeviceCode => 400, + } + } +} + +// ============================================================================ +// Additional DTOs (used by service, not in the handler module) +// ============================================================================ + +/// DTO for listing authorized devices in the user's profile. +#[derive(Debug, serde::Serialize)] +pub struct DeviceInfoDto { + pub id: String, + pub client_name: String, + pub scopes: String, + pub status: String, + pub created_at: String, + pub authorized_at: Option, + pub expires_at: String, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Generate a cryptographically random device_code (hex-encoded). +fn generate_device_code() -> String { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; DEVICE_CODE_BYTES]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Generate a human-readable user_code in the format "ABCD-1234". +fn generate_user_code() -> String { + use rand_core::{OsRng, RngCore}; + let mut rng_bytes = [0u8; 8]; + OsRng.fill_bytes(&mut rng_bytes); + + let letters: String = (0..USER_CODE_LETTER_LEN) + .map(|i| { + let b = rng_bytes[i] % 26; + (b'A' + b) as char + }) + .collect(); + + let digits: String = (0..USER_CODE_DIGIT_LEN) + .map(|i| { + let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10; + (b'0' + b) as char + }) + .collect(); + + format!("{}-{}", letters, digits) +} diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index a90be389..b8866358 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -398,12 +398,16 @@ impl FolderUseCase for FolderService { } // Rename folder — UPDATE RETURNING gives us the updated row directly - let folder = self.folder_storage.rename_folder(id, dto.name).await.map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("Failed to rename folder with ID: {}: {}", id, e), - ) - })?; + let folder = self + .folder_storage + .rename_folder(id, dto.name) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to rename folder with ID: {}: {}", id, e), + ) + })?; Ok(FolderDto::from(folder)) } @@ -455,12 +459,16 @@ impl FolderUseCase for FolderService { // Move folder — UPDATE RETURNING gives us the updated row directly let parent_ref = dto.parent_id.as_deref(); - let folder = self.folder_storage.move_folder(id, parent_ref).await.map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("Failed to move folder with ID: {}: {}", id, e), - ) - })?; + let folder = self + .folder_storage + .move_folder(id, parent_ref) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to move folder with ID: {}: {}", id, e), + ) + })?; Ok(FolderDto::from(folder)) } diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index bdaeed58..820988fd 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -3,8 +3,8 @@ pub mod app_password_service; pub mod auth_application_service; pub mod batch_operations; pub mod calendar_service; -pub mod device_auth_service; pub mod contact_service; +pub mod device_auth_service; pub mod favorites_service; pub mod file_management_service; pub mod file_retrieval_service; diff --git a/src/common/config.rs b/src/common/config.rs index 35b734df..83d33501 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -636,7 +636,8 @@ impl AppConfig { { config.auth.rate_limit.register_max_requests = val; } - if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::()) + if let Ok(v) = + env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::()) && let Ok(val) = v { config.auth.rate_limit.register_window_secs = val; diff --git a/src/common/di.rs b/src/common/di.rs index e5e4992c..713048fe 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -603,10 +603,11 @@ impl AppServiceFactory { Arc::new(crate::infrastructure::repositories::UserPgRepository::new( pool.clone(), )); - let session_repo: Arc = - Arc::new(crate::infrastructure::repositories::SessionPgRepository::new( - pool.clone(), - )); + let session_repo: Arc< + dyn crate::application::ports::auth_ports::SessionStoragePort, + > = Arc::new( + crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()), + ); let base_url = self.config.base_url(); let device_auth_svc = Arc::new(DeviceAuthService::new( @@ -625,8 +626,9 @@ impl AppServiceFactory { use crate::application::services::app_password_service::AppPasswordService; use crate::infrastructure::repositories::AppPasswordPgRepository; - let app_pw_repo: Arc = - Arc::new(AppPasswordPgRepository::new(pool.clone())); + let app_pw_repo: Arc< + dyn crate::application::ports::auth_ports::AppPasswordStoragePort, + > = Arc::new(AppPasswordPgRepository::new(pool.clone())); let hasher: Arc = Arc::new( crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( @@ -818,7 +820,8 @@ pub struct ApplicationServices { pub struct AuthServices { pub token_service: Arc, pub auth_application_service: Arc, - pub login_lockout: Arc, + pub login_lockout: + Arc, } /// Global application state for dependency injection diff --git a/src/domain/entities/device_code.rs b/src/domain/entities/device_code.rs index 25395920..7159913a 100644 --- a/src/domain/entities/device_code.rs +++ b/src/domain/entities/device_code.rs @@ -1,267 +1,262 @@ -//! Device Authorization Code entity (RFC 8628). -//! -//! Represents a pending or completed OAuth 2.0 Device Authorization Grant flow. - -use chrono::{DateTime, Duration, Utc}; -use uuid::Uuid; - -/// Status of a device authorization flow. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeviceCodeStatus { - /// Waiting for the user to authorize on the verification page. - Pending, - /// User approved — tokens are ready for the polling client. - Authorized, - /// User explicitly denied the request. - Denied, - /// The code expired before the user acted. - Expired, -} - -impl DeviceCodeStatus { - pub fn as_str(&self) -> &'static str { - match self { - Self::Pending => "pending", - Self::Authorized => "authorized", - Self::Denied => "denied", - Self::Expired => "expired", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "pending" => Some(Self::Pending), - "authorized" => Some(Self::Authorized), - "denied" => Some(Self::Denied), - "expired" => Some(Self::Expired), - _ => None, - } - } -} - -impl std::fmt::Display for DeviceCodeStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// Domain entity for a Device Authorization flow. -#[derive(Debug, Clone)] -pub struct DeviceCode { - id: String, - device_code: String, - user_code: String, - client_name: String, - scopes: String, - status: DeviceCodeStatus, - user_id: Option, - access_token: Option, - refresh_token: Option, - verification_uri: String, - verification_uri_complete: Option, - expires_at: DateTime, - poll_interval_secs: i32, - last_poll_at: Option>, - created_at: DateTime, - authorized_at: Option>, -} - -impl DeviceCode { - /// Create a new pending device code flow. - /// - /// * `device_code` — opaque token for client polling (64 hex chars) - /// * `user_code` — short human-readable code (e.g. "ABCD-1234") - /// * `client_name` — display name of the requesting client - /// * `scopes` — requested scopes (e.g. "webdav,caldav,carddav") - /// * `verification_uri` — URL the user must visit - /// * `expires_in_secs` — TTL for the device code - /// * `poll_interval_secs` — minimum polling interval - pub fn new( - device_code: String, - user_code: String, - client_name: String, - scopes: String, - verification_uri: String, - verification_uri_complete: Option, - expires_in_secs: i64, - poll_interval_secs: i32, - ) -> Self { - let now = Utc::now(); - Self { - id: Uuid::new_v4().to_string(), - device_code, - user_code, - client_name, - scopes, - status: DeviceCodeStatus::Pending, - user_id: None, - access_token: None, - refresh_token: None, - verification_uri, - verification_uri_complete, - expires_at: now + Duration::seconds(expires_in_secs), - poll_interval_secs, - last_poll_at: None, - created_at: now, - authorized_at: None, - } - } - - /// Reconstruct from database row. - #[allow(clippy::too_many_arguments)] - pub fn from_raw( - id: String, - device_code: String, - user_code: String, - client_name: String, - scopes: String, - status: DeviceCodeStatus, - user_id: Option, - access_token: Option, - refresh_token: Option, - verification_uri: String, - verification_uri_complete: Option, - expires_at: DateTime, - poll_interval_secs: i32, - last_poll_at: Option>, - created_at: DateTime, - authorized_at: Option>, - ) -> Self { - Self { - id, - device_code, - user_code, - client_name, - scopes, - status, - user_id, - access_token, - refresh_token, - verification_uri, - verification_uri_complete, - expires_at, - poll_interval_secs, - last_poll_at, - created_at, - authorized_at, - } - } - - // ── Getters ────────────────────────────────────────────────── - - pub fn id(&self) -> &str { - &self.id - } - - pub fn device_code(&self) -> &str { - &self.device_code - } - - pub fn user_code(&self) -> &str { - &self.user_code - } - - pub fn client_name(&self) -> &str { - &self.client_name - } - - pub fn scopes(&self) -> &str { - &self.scopes - } - - pub fn status(&self) -> DeviceCodeStatus { - self.status - } - - pub fn user_id(&self) -> Option<&str> { - self.user_id.as_deref() - } - - pub fn access_token(&self) -> Option<&str> { - self.access_token.as_deref() - } - - pub fn refresh_token(&self) -> Option<&str> { - self.refresh_token.as_deref() - } - - pub fn verification_uri(&self) -> &str { - &self.verification_uri - } - - pub fn verification_uri_complete(&self) -> Option<&str> { - self.verification_uri_complete.as_deref() - } - - pub fn expires_at(&self) -> DateTime { - self.expires_at - } - - pub fn poll_interval_secs(&self) -> i32 { - self.poll_interval_secs - } - - pub fn last_poll_at(&self) -> Option> { - self.last_poll_at - } - - pub fn created_at(&self) -> DateTime { - self.created_at - } - - pub fn authorized_at(&self) -> Option> { - self.authorized_at - } - - // ── Business logic ─────────────────────────────────────────── - - /// Whether the device code has expired. - pub fn is_expired(&self) -> bool { - Utc::now() > self.expires_at - } - - /// Seconds remaining until expiry (clamped to 0). - pub fn seconds_remaining(&self) -> i64 { - let remaining = (self.expires_at - Utc::now()).num_seconds(); - remaining.max(0) - } - - /// Whether the client is polling too fast (within poll_interval_secs). - pub fn is_polling_too_fast(&self) -> bool { - if let Some(last) = self.last_poll_at { - let elapsed = (Utc::now() - last).num_seconds(); - elapsed < self.poll_interval_secs as i64 - } else { - false - } - } - - /// Record a poll attempt timestamp. - pub fn record_poll(&mut self) { - self.last_poll_at = Some(Utc::now()); - } - - /// Authorize this device code for a specific user, storing the tokens. - pub fn authorize( - &mut self, - user_id: String, - access_token: String, - refresh_token: String, - ) { - self.status = DeviceCodeStatus::Authorized; - self.user_id = Some(user_id); - self.access_token = Some(access_token); - self.refresh_token = Some(refresh_token); - self.authorized_at = Some(Utc::now()); - } - - /// Deny this device code. - pub fn deny(&mut self) { - self.status = DeviceCodeStatus::Denied; - } - - /// Mark as expired. - pub fn mark_expired(&mut self) { - self.status = DeviceCodeStatus::Expired; - } -} +//! Device Authorization Code entity (RFC 8628). +//! +//! Represents a pending or completed OAuth 2.0 Device Authorization Grant flow. + +use chrono::{DateTime, Duration, Utc}; +use uuid::Uuid; + +/// Status of a device authorization flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceCodeStatus { + /// Waiting for the user to authorize on the verification page. + Pending, + /// User approved — tokens are ready for the polling client. + Authorized, + /// User explicitly denied the request. + Denied, + /// The code expired before the user acted. + Expired, +} + +impl DeviceCodeStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Authorized => "authorized", + Self::Denied => "denied", + Self::Expired => "expired", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "authorized" => Some(Self::Authorized), + "denied" => Some(Self::Denied), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +impl std::fmt::Display for DeviceCodeStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Domain entity for a Device Authorization flow. +#[derive(Debug, Clone)] +pub struct DeviceCode { + id: String, + device_code: String, + user_code: String, + client_name: String, + scopes: String, + status: DeviceCodeStatus, + user_id: Option, + access_token: Option, + refresh_token: Option, + verification_uri: String, + verification_uri_complete: Option, + expires_at: DateTime, + poll_interval_secs: i32, + last_poll_at: Option>, + created_at: DateTime, + authorized_at: Option>, +} + +impl DeviceCode { + /// Create a new pending device code flow. + /// + /// * `device_code` — opaque token for client polling (64 hex chars) + /// * `user_code` — short human-readable code (e.g. "ABCD-1234") + /// * `client_name` — display name of the requesting client + /// * `scopes` — requested scopes (e.g. "webdav,caldav,carddav") + /// * `verification_uri` — URL the user must visit + /// * `expires_in_secs` — TTL for the device code + /// * `poll_interval_secs` — minimum polling interval + pub fn new( + device_code: String, + user_code: String, + client_name: String, + scopes: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in_secs: i64, + poll_interval_secs: i32, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + device_code, + user_code, + client_name, + scopes, + status: DeviceCodeStatus::Pending, + user_id: None, + access_token: None, + refresh_token: None, + verification_uri, + verification_uri_complete, + expires_at: now + Duration::seconds(expires_in_secs), + poll_interval_secs, + last_poll_at: None, + created_at: now, + authorized_at: None, + } + } + + /// Reconstruct from database row. + #[allow(clippy::too_many_arguments)] + pub fn from_raw( + id: String, + device_code: String, + user_code: String, + client_name: String, + scopes: String, + status: DeviceCodeStatus, + user_id: Option, + access_token: Option, + refresh_token: Option, + verification_uri: String, + verification_uri_complete: Option, + expires_at: DateTime, + poll_interval_secs: i32, + last_poll_at: Option>, + created_at: DateTime, + authorized_at: Option>, + ) -> Self { + Self { + id, + device_code, + user_code, + client_name, + scopes, + status, + user_id, + access_token, + refresh_token, + verification_uri, + verification_uri_complete, + expires_at, + poll_interval_secs, + last_poll_at, + created_at, + authorized_at, + } + } + + // ── Getters ────────────────────────────────────────────────── + + pub fn id(&self) -> &str { + &self.id + } + + pub fn device_code(&self) -> &str { + &self.device_code + } + + pub fn user_code(&self) -> &str { + &self.user_code + } + + pub fn client_name(&self) -> &str { + &self.client_name + } + + pub fn scopes(&self) -> &str { + &self.scopes + } + + pub fn status(&self) -> DeviceCodeStatus { + self.status + } + + pub fn user_id(&self) -> Option<&str> { + self.user_id.as_deref() + } + + pub fn access_token(&self) -> Option<&str> { + self.access_token.as_deref() + } + + pub fn refresh_token(&self) -> Option<&str> { + self.refresh_token.as_deref() + } + + pub fn verification_uri(&self) -> &str { + &self.verification_uri + } + + pub fn verification_uri_complete(&self) -> Option<&str> { + self.verification_uri_complete.as_deref() + } + + pub fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn poll_interval_secs(&self) -> i32 { + self.poll_interval_secs + } + + pub fn last_poll_at(&self) -> Option> { + self.last_poll_at + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } + + pub fn authorized_at(&self) -> Option> { + self.authorized_at + } + + // ── Business logic ─────────────────────────────────────────── + + /// Whether the device code has expired. + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + /// Seconds remaining until expiry (clamped to 0). + pub fn seconds_remaining(&self) -> i64 { + let remaining = (self.expires_at - Utc::now()).num_seconds(); + remaining.max(0) + } + + /// Whether the client is polling too fast (within poll_interval_secs). + pub fn is_polling_too_fast(&self) -> bool { + if let Some(last) = self.last_poll_at { + let elapsed = (Utc::now() - last).num_seconds(); + elapsed < self.poll_interval_secs as i64 + } else { + false + } + } + + /// Record a poll attempt timestamp. + pub fn record_poll(&mut self) { + self.last_poll_at = Some(Utc::now()); + } + + /// Authorize this device code for a specific user, storing the tokens. + pub fn authorize(&mut self, user_id: String, access_token: String, refresh_token: String) { + self.status = DeviceCodeStatus::Authorized; + self.user_id = Some(user_id); + self.access_token = Some(access_token); + self.refresh_token = Some(refresh_token); + self.authorized_at = Some(Utc::now()); + } + + /// Deny this device code. + pub fn deny(&mut self) { + self.status = DeviceCodeStatus::Denied; + } + + /// Mark as expired. + pub fn mark_expired(&mut self) { + self.status = DeviceCodeStatus::Expired; + } +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 42ecad96..44565444 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -3,6 +3,7 @@ pub mod pg; // Re-exportar para facilitar acceso pub use pg::{ - AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository, - FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository, + AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, + FileBlobWriteRepository, FolderDbRepository, SessionPgRepository, TrashDbRepository, + UserPgRepository, }; diff --git a/src/infrastructure/repositories/pg/app_password_pg_repository.rs b/src/infrastructure/repositories/pg/app_password_pg_repository.rs index ec7b1bc6..51ee2c18 100644 --- a/src/infrastructure/repositories/pg/app_password_pg_repository.rs +++ b/src/infrastructure/repositories/pg/app_password_pg_repository.rs @@ -1,188 +1,178 @@ -//! PostgreSQL repository for App Passwords. - -use crate::application::ports::auth_ports::AppPasswordStoragePort; -use crate::common::errors::DomainError; -use crate::domain::entities::app_password::AppPassword; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use std::sync::Arc; - -pub struct AppPasswordPgRepository { - pool: Arc, -} - -impl AppPasswordPgRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } - - fn pool(&self) -> &PgPool { - &self.pool - } -} - -#[async_trait] -impl AppPasswordStoragePort for AppPasswordPgRepository { - async fn create(&self, ap: AppPassword) -> Result { - sqlx::query( - r#" - INSERT INTO auth.app_passwords - (id, user_id, label, password_hash, prefix, scopes, - created_at, last_used_at, expires_at, active) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - "#, - ) - .bind(&ap.id) - .bind(&ap.user_id) - .bind(&ap.label) - .bind(&ap.password_hash) - .bind(&ap.prefix) - .bind(&ap.scopes) - .bind(ap.created_at) - .bind(ap.last_used_at) - .bind(ap.expires_at) - .bind(ap.active) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("create: {e}")))?; - - Ok(ap) - } - - async fn list_by_user(&self, user_id: &str) -> Result, DomainError> { - let rows = sqlx::query_as::<_, AppPasswordRow>( - r#" - SELECT id, user_id, label, password_hash, prefix, scopes, - created_at, last_used_at, expires_at, active - FROM auth.app_passwords - WHERE user_id = $1 - ORDER BY created_at DESC - "#, - ) - .bind(user_id) - .fetch_all(self.pool()) - .await - .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("list: {e}")))?; - - Ok(rows.into_iter().map(|r| r.into()).collect()) - } - - async fn get_by_id(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, AppPasswordRow>( - r#" - SELECT id, user_id, label, password_hash, prefix, scopes, - created_at, last_used_at, expires_at, active - FROM auth.app_passwords - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_by_id: {e}")))? - .ok_or_else(|| DomainError::not_found("AppPassword", id))?; - - Ok(row.into()) - } - - async fn get_active_by_user_id( - &self, - user_id: &str, - ) -> Result, DomainError> { - let rows = sqlx::query_as::<_, AppPasswordRow>( - r#" - SELECT id, user_id, label, password_hash, prefix, scopes, - created_at, last_used_at, expires_at, active - FROM auth.app_passwords - WHERE user_id = $1 - AND active = TRUE - AND (expires_at IS NULL OR expires_at > NOW()) - "#, - ) - .bind(user_id) - .fetch_all(self.pool()) - .await - .map_err(|e| { - DomainError::internal_error("AppPasswordPg", format!("get_active: {e}")) - })?; - - Ok(rows.into_iter().map(|r| r.into()).collect()) - } - - async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> { - sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1") - .bind(id) - .execute(self.pool()) - .await - .map_err(|e| { - DomainError::internal_error("AppPasswordPg", format!("touch: {e}")) - })?; - Ok(()) - } - - async fn revoke(&self, id: &str) -> Result<(), DomainError> { - let result = - sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1") - .bind(id) - .execute(self.pool()) - .await - .map_err(|e| { - DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")) - })?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("AppPassword", id)); - } - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query( - r#" - DELETE FROM auth.app_passwords - WHERE (active = FALSE) - OR (expires_at IS NOT NULL AND expires_at < NOW()) - "#, - ) - .execute(self.pool()) - .await - .map_err(|e| { - DomainError::internal_error("AppPasswordPg", format!("delete_expired: {e}")) - })?; - - Ok(result.rows_affected()) - } -} - -/// Internal row struct for sqlx mapping. -#[derive(sqlx::FromRow)] -struct AppPasswordRow { - id: String, - user_id: String, - label: String, - password_hash: String, - prefix: String, - scopes: String, - created_at: DateTime, - last_used_at: Option>, - expires_at: Option>, - active: bool, -} - -impl From for AppPassword { - fn from(r: AppPasswordRow) -> Self { - AppPassword { - id: r.id, - user_id: r.user_id, - label: r.label, - password_hash: r.password_hash, - prefix: r.prefix, - scopes: r.scopes, - created_at: r.created_at, - last_used_at: r.last_used_at, - expires_at: r.expires_at, - active: r.active, - } - } -} +//! PostgreSQL repository for App Passwords. + +use crate::application::ports::auth_ports::AppPasswordStoragePort; +use crate::common::errors::DomainError; +use crate::domain::entities::app_password::AppPassword; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use std::sync::Arc; + +pub struct AppPasswordPgRepository { + pool: Arc, +} + +impl AppPasswordPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn pool(&self) -> &PgPool { + &self.pool + } +} + +#[async_trait] +impl AppPasswordStoragePort for AppPasswordPgRepository { + async fn create(&self, ap: AppPassword) -> Result { + sqlx::query( + r#" + INSERT INTO auth.app_passwords + (id, user_id, label, password_hash, prefix, scopes, + created_at, last_used_at, expires_at, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + ) + .bind(&ap.id) + .bind(&ap.user_id) + .bind(&ap.label) + .bind(&ap.password_hash) + .bind(&ap.prefix) + .bind(&ap.scopes) + .bind(ap.created_at) + .bind(ap.last_used_at) + .bind(ap.expires_at) + .bind(ap.active) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("create: {e}")))?; + + Ok(ap) + } + + async fn list_by_user(&self, user_id: &str) -> Result, DomainError> { + let rows = sqlx::query_as::<_, AppPasswordRow>( + r#" + SELECT id, user_id, label, password_hash, prefix, scopes, + created_at, last_used_at, expires_at, active + FROM auth.app_passwords + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("list: {e}")))?; + + Ok(rows.into_iter().map(|r| r.into()).collect()) + } + + async fn get_by_id(&self, id: &str) -> Result { + let row = sqlx::query_as::<_, AppPasswordRow>( + r#" + SELECT id, user_id, label, password_hash, prefix, scopes, + created_at, last_used_at, expires_at, active + FROM auth.app_passwords + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_by_id: {e}")))? + .ok_or_else(|| DomainError::not_found("AppPassword", id))?; + + Ok(row.into()) + } + + async fn get_active_by_user_id(&self, user_id: &str) -> Result, DomainError> { + let rows = sqlx::query_as::<_, AppPasswordRow>( + r#" + SELECT id, user_id, label, password_hash, prefix, scopes, + created_at, last_used_at, expires_at, active + FROM auth.app_passwords + WHERE user_id = $1 + AND active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + "#, + ) + .bind(user_id) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_active: {e}")))?; + + Ok(rows.into_iter().map(|r| r.into()).collect()) + } + + async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> { + sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1") + .bind(id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("touch: {e}")))?; + Ok(()) + } + + async fn revoke(&self, id: &str) -> Result<(), DomainError> { + let result = sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1") + .bind(id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?; + + if result.rows_affected() == 0 { + return Err(DomainError::not_found("AppPassword", id)); + } + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let result = sqlx::query( + r#" + DELETE FROM auth.app_passwords + WHERE (active = FALSE) + OR (expires_at IS NOT NULL AND expires_at < NOW()) + "#, + ) + .execute(self.pool()) + .await + .map_err(|e| { + DomainError::internal_error("AppPasswordPg", format!("delete_expired: {e}")) + })?; + + Ok(result.rows_affected()) + } +} + +/// Internal row struct for sqlx mapping. +#[derive(sqlx::FromRow)] +struct AppPasswordRow { + id: String, + user_id: String, + label: String, + password_hash: String, + prefix: String, + scopes: String, + created_at: DateTime, + last_used_at: Option>, + expires_at: Option>, + active: bool, +} + +impl From for AppPassword { + fn from(r: AppPasswordRow) -> Self { + AppPassword { + id: r.id, + user_id: r.user_id, + label: r.label, + password_hash: r.password_hash, + prefix: r.prefix, + scopes: r.scopes, + created_at: r.created_at, + last_used_at: r.last_used_at, + expires_at: r.expires_at, + active: r.active, + } + } +} diff --git a/src/infrastructure/repositories/pg/device_code_pg_repository.rs b/src/infrastructure/repositories/pg/device_code_pg_repository.rs index f52ecacf..c34d375a 100644 --- a/src/infrastructure/repositories/pg/device_code_pg_repository.rs +++ b/src/infrastructure/repositories/pg/device_code_pg_repository.rs @@ -1,261 +1,259 @@ -//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes. - -use async_trait::async_trait; -use sqlx::{PgPool, Row}; -use std::sync::Arc; - -use crate::application::ports::auth_ports::DeviceCodeStoragePort; -use crate::common::errors::{DomainError, ErrorKind}; -use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; - -pub struct DeviceCodePgRepository { - pool: Arc, -} - -impl DeviceCodePgRepository { - pub fn new(pool: Arc) -> Self { - Self { pool } - } - - fn map_row(row: &sqlx::postgres::PgRow) -> Result { - let status_str: String = row.try_get("status").map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to read status: {}", e), - ) - })?; - - let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired); - - Ok(DeviceCode::from_raw( - row.try_get("id").unwrap_or_default(), - row.try_get("device_code").unwrap_or_default(), - row.try_get("user_code").unwrap_or_default(), - row.try_get("client_name").unwrap_or_default(), - row.try_get("scopes").unwrap_or_default(), - status, - row.try_get("user_id").ok(), - row.try_get("access_token").ok(), - row.try_get("refresh_token").ok(), - row.try_get("verification_uri").unwrap_or_default(), - row.try_get("verification_uri_complete").ok(), - row.try_get("expires_at").unwrap_or_default(), - row.try_get::("poll_interval_secs").unwrap_or(5), - row.try_get("last_poll_at").ok(), - row.try_get("created_at").unwrap_or_default(), - row.try_get("authorized_at").ok(), - )) - } -} - -#[async_trait] -impl DeviceCodeStoragePort for DeviceCodePgRepository { - async fn create_device_code(&self, dc: DeviceCode) -> Result { - sqlx::query( - r#" - INSERT INTO auth.device_codes ( - id, device_code, user_code, client_name, scopes, status, - user_id, access_token, refresh_token, - verification_uri, verification_uri_complete, - expires_at, poll_interval_secs, last_poll_at, - created_at, authorized_at - ) VALUES ( - $1, $2, $3, $4, $5, $6::auth.device_code_status, - $7, $8, $9, - $10, $11, - $12, $13, $14, - $15, $16 - ) - "#, - ) - .bind(dc.id()) - .bind(dc.device_code()) - .bind(dc.user_code()) - .bind(dc.client_name()) - .bind(dc.scopes()) - .bind(dc.status().as_str()) - .bind(dc.user_id()) - .bind(dc.access_token()) - .bind(dc.refresh_token()) - .bind(dc.verification_uri()) - .bind(dc.verification_uri_complete()) - .bind(dc.expires_at()) - .bind(dc.poll_interval_secs()) - .bind(dc.last_poll_at()) - .bind(dc.created_at()) - .bind(dc.authorized_at()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to create device code: {}", e), - ) - })?; - - Ok(dc) - } - - async fn get_by_device_code(&self, device_code: &str) -> Result { - let row = sqlx::query( - r#" - SELECT id, device_code, user_code, client_name, scopes, - status::text AS status, user_id, access_token, refresh_token, - verification_uri, verification_uri_complete, - expires_at, poll_interval_secs, last_poll_at, - created_at, authorized_at - FROM auth.device_codes - WHERE device_code = $1 - "#, - ) - .bind(device_code) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => DomainError::new( - ErrorKind::NotFound, - "DeviceCode", - "Device code not found", - ), - _ => DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to fetch device code: {}", e), - ), - })?; - - Self::map_row(&row) - } - - async fn get_pending_by_user_code(&self, user_code: &str) -> Result { - let row = sqlx::query( - r#" - SELECT id, device_code, user_code, client_name, scopes, - status::text AS status, user_id, access_token, refresh_token, - verification_uri, verification_uri_complete, - expires_at, poll_interval_secs, last_poll_at, - created_at, authorized_at - FROM auth.device_codes - WHERE user_code = $1 - AND status = 'pending' - AND expires_at > NOW() - "#, - ) - .bind(user_code) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => DomainError::new( - ErrorKind::NotFound, - "DeviceCode", - "User code not found or expired", - ), - _ => DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to fetch by user code: {}", e), - ), - })?; - - Self::map_row(&row) - } - - async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> { - sqlx::query( - r#" - UPDATE auth.device_codes SET - status = $2::auth.device_code_status, - user_id = $3, - access_token = $4, - refresh_token = $5, - last_poll_at = $6, - authorized_at = $7 - WHERE id = $1 - "#, - ) - .bind(dc.id()) - .bind(dc.status().as_str()) - .bind(dc.user_id()) - .bind(dc.access_token()) - .bind(dc.refresh_token()) - .bind(dc.last_poll_at()) - .bind(dc.authorized_at()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to update device code: {}", e), - ) - })?; - - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query( - r#" - DELETE FROM auth.device_codes - WHERE expires_at < NOW() - AND status IN ('pending', 'expired') - "#, - ) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to delete expired device codes: {}", e), - ) - })?; - - Ok(result.rows_affected()) - } - - async fn list_by_user(&self, user_id: &str) -> Result, DomainError> { - let rows = sqlx::query( - r#" - SELECT id, device_code, user_code, client_name, scopes, - status::text AS status, user_id, access_token, refresh_token, - verification_uri, verification_uri_complete, - expires_at, poll_interval_secs, last_poll_at, - created_at, authorized_at - FROM auth.device_codes - WHERE user_id = $1 - ORDER BY created_at DESC - "#, - ) - .bind(user_id) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to list device codes: {}", e), - ) - })?; - - rows.iter().map(Self::map_row).collect() - } - - async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM auth.device_codes WHERE id = $1") - .bind(id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::DatabaseError, - "DeviceCode", - format!("Failed to delete device code: {}", e), - ) - })?; - - Ok(()) - } -} +//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes. + +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use std::sync::Arc; + +use crate::application::ports::auth_ports::DeviceCodeStoragePort; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; + +pub struct DeviceCodePgRepository { + pool: Arc, +} + +impl DeviceCodePgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn map_row(row: &sqlx::postgres::PgRow) -> Result { + let status_str: String = row.try_get("status").map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to read status: {}", e), + ) + })?; + + let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired); + + Ok(DeviceCode::from_raw( + row.try_get("id").unwrap_or_default(), + row.try_get("device_code").unwrap_or_default(), + row.try_get("user_code").unwrap_or_default(), + row.try_get("client_name").unwrap_or_default(), + row.try_get("scopes").unwrap_or_default(), + status, + row.try_get("user_id").ok(), + row.try_get("access_token").ok(), + row.try_get("refresh_token").ok(), + row.try_get("verification_uri").unwrap_or_default(), + row.try_get("verification_uri_complete").ok(), + row.try_get("expires_at").unwrap_or_default(), + row.try_get::("poll_interval_secs").unwrap_or(5), + row.try_get("last_poll_at").ok(), + row.try_get("created_at").unwrap_or_default(), + row.try_get("authorized_at").ok(), + )) + } +} + +#[async_trait] +impl DeviceCodeStoragePort for DeviceCodePgRepository { + async fn create_device_code(&self, dc: DeviceCode) -> Result { + sqlx::query( + r#" + INSERT INTO auth.device_codes ( + id, device_code, user_code, client_name, scopes, status, + user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + ) VALUES ( + $1, $2, $3, $4, $5, $6::auth.device_code_status, + $7, $8, $9, + $10, $11, + $12, $13, $14, + $15, $16 + ) + "#, + ) + .bind(dc.id()) + .bind(dc.device_code()) + .bind(dc.user_code()) + .bind(dc.client_name()) + .bind(dc.scopes()) + .bind(dc.status().as_str()) + .bind(dc.user_id()) + .bind(dc.access_token()) + .bind(dc.refresh_token()) + .bind(dc.verification_uri()) + .bind(dc.verification_uri_complete()) + .bind(dc.expires_at()) + .bind(dc.poll_interval_secs()) + .bind(dc.last_poll_at()) + .bind(dc.created_at()) + .bind(dc.authorized_at()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to create device code: {}", e), + ) + })?; + + Ok(dc) + } + + async fn get_by_device_code(&self, device_code: &str) -> Result { + let row = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE device_code = $1 + "#, + ) + .bind(device_code) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => { + DomainError::new(ErrorKind::NotFound, "DeviceCode", "Device code not found") + } + _ => DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to fetch device code: {}", e), + ), + })?; + + Self::map_row(&row) + } + + async fn get_pending_by_user_code(&self, user_code: &str) -> Result { + let row = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE user_code = $1 + AND status = 'pending' + AND expires_at > NOW() + "#, + ) + .bind(user_code) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => DomainError::new( + ErrorKind::NotFound, + "DeviceCode", + "User code not found or expired", + ), + _ => DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to fetch by user code: {}", e), + ), + })?; + + Self::map_row(&row) + } + + async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> { + sqlx::query( + r#" + UPDATE auth.device_codes SET + status = $2::auth.device_code_status, + user_id = $3, + access_token = $4, + refresh_token = $5, + last_poll_at = $6, + authorized_at = $7 + WHERE id = $1 + "#, + ) + .bind(dc.id()) + .bind(dc.status().as_str()) + .bind(dc.user_id()) + .bind(dc.access_token()) + .bind(dc.refresh_token()) + .bind(dc.last_poll_at()) + .bind(dc.authorized_at()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to update device code: {}", e), + ) + })?; + + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let result = sqlx::query( + r#" + DELETE FROM auth.device_codes + WHERE expires_at < NOW() + AND status IN ('pending', 'expired') + "#, + ) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to delete expired device codes: {}", e), + ) + })?; + + Ok(result.rows_affected()) + } + + async fn list_by_user(&self, user_id: &str) -> Result, DomainError> { + let rows = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to list device codes: {}", e), + ) + })?; + + rows.iter().map(Self::map_row).collect() + } + + async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM auth.device_codes WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to delete device code: {}", e), + ) + })?; + + Ok(()) + } +} diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index ce6ca2b0..4a615b45 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -268,10 +268,18 @@ impl FolderRepository for FolderDbRepository { limit: usize, include_total: bool, ) -> Result<(Vec, Option), DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64, i64)> = - if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<( + String, + String, + String, + Option, + String, + i64, + i64, + i64, + )> = if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, @@ -281,15 +289,15 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(pid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, @@ -299,13 +307,13 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $1 OFFSET $2 "#, - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { @@ -333,10 +341,18 @@ impl FolderRepository for FolderDbRepository { limit: usize, include_total: bool, ) -> Result<(Vec, Option), DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64, i64)> = - if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<( + String, + String, + String, + Option, + String, + i64, + i64, + i64, + )> = if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, @@ -346,16 +362,16 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $3 OFFSET $4 "#, - ) - .bind(pid) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, @@ -365,16 +381,14 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| { - DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")) - })?; + ) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; let total = if include_total { Some(rows.first().map_or(0, |r| r.7) as usize) @@ -867,10 +881,7 @@ impl FolderRepository for FolderDbRepository { user_id: &str, ) -> Result, DomainError> { let (where_extra, name_pattern) = match name_contains { - Some(name) if name.len() >= 3 => ( - " AND fo.name ILIKE $3", - Some(format!("%{}%", name)), - ), + Some(name) if name.len() >= 3 => (" AND fo.name ILIKE $3", Some(format!("%{}%", name))), _ => ("", None), }; diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index e178dba9..930c2ba2 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -335,7 +335,9 @@ mod tests { let claims1 = service.validate_token(&token).expect("Should validate"); // Second call: cache hit — skips HMAC, returns cloned claims - let claims2 = service.validate_token(&token).expect("Should validate from cache"); + let claims2 = service + .validate_token(&token) + .expect("Should validate from cache"); assert_eq!(claims1.sub, claims2.sub); assert_eq!(claims1.username, claims2.username); diff --git a/src/infrastructure/services/login_lockout_service.rs b/src/infrastructure/services/login_lockout_service.rs index 51f68f6d..b0c586ad 100644 --- a/src/infrastructure/services/login_lockout_service.rs +++ b/src/infrastructure/services/login_lockout_service.rs @@ -1,153 +1,150 @@ -//! Account lockout service — blocks login for an account after N consecutive -//! failed attempts. -//! -//! Uses a `moka` TTL cache so that: -//! * Failed-attempt counters automatically expire after the lockout window. -//! * No database writes are needed — this is **in-memory** and therefore -//! per-instance. If OxiCloud is deployed behind a load balancer with -//! multiple replicas, a sticky-session or shared Redis store would be -//! needed for cross-instance coordination (out of scope for v1). -//! -//! Typical flow: -//! 1. **Before password verification** → call [`LoginLockoutService::check`]. -//! If the account is locked, return `403` immediately without touching -//! Argon2 (saves CPU). -//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`]. -//! 3. **After successful login** → call [`LoginLockoutService::record_success`] -//! to reset the counter. - -use moka::sync::Cache; -use std::time::Duration; - -/// Tracks consecutive failures for a single username. -#[derive(Clone, Debug)] -struct FailureRecord { - /// Number of consecutive failed attempts. - count: u32, -} - -/// In-memory account lockout tracker. -#[derive(Clone)] -pub struct LoginLockoutService { - /// Maps `username -> FailureRecord`. TTL = lockout window. - cache: Cache, - /// Maximum consecutive failures before the account is temporarily locked. - max_failures: u32, - /// How long the lockout lasts (seconds). - lockout_secs: u64, -} - -impl LoginLockoutService { - /// Create a new lockout service. - /// - /// * `max_failures` — e.g. `5` (lock after 5 bad passwords) - /// * `lockout_secs` — e.g. `900` (15-minute lockout) - /// * `max_accounts` — upper bound on tracked accounts (evicts LRU) - pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self { - let cache = Cache::builder() - .time_to_live(Duration::from_secs(lockout_secs)) - .max_capacity(max_accounts) - .build(); - Self { - cache, - max_failures, - lockout_secs, - } - } - - /// Check whether the account is currently locked. - /// - /// Returns `Ok(())` if the user may attempt login, or - /// `Err(remaining_secs)` with the *approximate* remaining lockout time. - pub fn check(&self, username: &str) -> Result<(), u64> { - if let Some(rec) = self.cache.get(&username.to_lowercase()) { - if rec.count >= self.max_failures { - // The entry exists and is over the threshold. Because moka - // evicts at TTL we know the lockout window has not yet elapsed. - return Err(self.lockout_secs); - } - } - Ok(()) - } - - /// Record a failed login attempt. Returns the new failure count. - pub fn record_failure(&self, username: &str) -> u32 { - let key = username.to_lowercase(); - let new_count = self - .cache - .get(&key) - .map(|r| r.count + 1) - .unwrap_or(1); - self.cache.insert(key.clone(), FailureRecord { count: new_count }); - - if new_count >= self.max_failures { - tracing::warn!( - username = %username, - attempts = new_count, - lockout_secs = self.lockout_secs, - "Account temporarily locked after {} consecutive failed login attempts", - new_count, - ); - } - new_count - } - - /// Record a successful login — resets the failure counter. - pub fn record_success(&self, username: &str) { - self.cache.invalidate(&username.to_lowercase()); - } - - /// Maximum failures before lockout (used to inform callers / error messages). - pub fn max_failures(&self) -> u32 { - self.max_failures - } - - /// Lockout duration in seconds. - pub fn lockout_secs(&self) -> u64 { - self.lockout_secs - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn allows_login_under_threshold() { - let svc = LoginLockoutService::new(3, 60, 100); - assert!(svc.check("alice").is_ok()); - svc.record_failure("alice"); - svc.record_failure("alice"); - // 2 failures — still under threshold - assert!(svc.check("alice").is_ok()); - } - - #[test] - fn locks_after_threshold() { - let svc = LoginLockoutService::new(3, 60, 100); - svc.record_failure("bob"); - svc.record_failure("bob"); - svc.record_failure("bob"); - assert!(svc.check("bob").is_err()); - } - - #[test] - fn resets_on_success() { - let svc = LoginLockoutService::new(3, 60, 100); - svc.record_failure("carol"); - svc.record_failure("carol"); - svc.record_success("carol"); - // Counter reset — should be allowed again - assert!(svc.check("carol").is_ok()); - svc.record_failure("carol"); // starts over at 1 - assert!(svc.check("carol").is_ok()); - } - - #[test] - fn case_insensitive() { - let svc = LoginLockoutService::new(2, 60, 100); - svc.record_failure("Dave"); - svc.record_failure("dave"); - assert!(svc.check("DAVE").is_err()); - } -} +//! Account lockout service — blocks login for an account after N consecutive +//! failed attempts. +//! +//! Uses a `moka` TTL cache so that: +//! * Failed-attempt counters automatically expire after the lockout window. +//! * No database writes are needed — this is **in-memory** and therefore +//! per-instance. If OxiCloud is deployed behind a load balancer with +//! multiple replicas, a sticky-session or shared Redis store would be +//! needed for cross-instance coordination (out of scope for v1). +//! +//! Typical flow: +//! 1. **Before password verification** → call [`LoginLockoutService::check`]. +//! If the account is locked, return `403` immediately without touching +//! Argon2 (saves CPU). +//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`]. +//! 3. **After successful login** → call [`LoginLockoutService::record_success`] +//! to reset the counter. + +use moka::sync::Cache; +use std::time::Duration; + +/// Tracks consecutive failures for a single username. +#[derive(Clone, Debug)] +struct FailureRecord { + /// Number of consecutive failed attempts. + count: u32, +} + +/// In-memory account lockout tracker. +#[derive(Clone)] +pub struct LoginLockoutService { + /// Maps `username -> FailureRecord`. TTL = lockout window. + cache: Cache, + /// Maximum consecutive failures before the account is temporarily locked. + max_failures: u32, + /// How long the lockout lasts (seconds). + lockout_secs: u64, +} + +impl LoginLockoutService { + /// Create a new lockout service. + /// + /// * `max_failures` — e.g. `5` (lock after 5 bad passwords) + /// * `lockout_secs` — e.g. `900` (15-minute lockout) + /// * `max_accounts` — upper bound on tracked accounts (evicts LRU) + pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self { + let cache = Cache::builder() + .time_to_live(Duration::from_secs(lockout_secs)) + .max_capacity(max_accounts) + .build(); + Self { + cache, + max_failures, + lockout_secs, + } + } + + /// Check whether the account is currently locked. + /// + /// Returns `Ok(())` if the user may attempt login, or + /// `Err(remaining_secs)` with the *approximate* remaining lockout time. + pub fn check(&self, username: &str) -> Result<(), u64> { + if let Some(rec) = self.cache.get(&username.to_lowercase()) { + if rec.count >= self.max_failures { + // The entry exists and is over the threshold. Because moka + // evicts at TTL we know the lockout window has not yet elapsed. + return Err(self.lockout_secs); + } + } + Ok(()) + } + + /// Record a failed login attempt. Returns the new failure count. + pub fn record_failure(&self, username: &str) -> u32 { + let key = username.to_lowercase(); + let new_count = self.cache.get(&key).map(|r| r.count + 1).unwrap_or(1); + self.cache + .insert(key.clone(), FailureRecord { count: new_count }); + + if new_count >= self.max_failures { + tracing::warn!( + username = %username, + attempts = new_count, + lockout_secs = self.lockout_secs, + "Account temporarily locked after {} consecutive failed login attempts", + new_count, + ); + } + new_count + } + + /// Record a successful login — resets the failure counter. + pub fn record_success(&self, username: &str) { + self.cache.invalidate(&username.to_lowercase()); + } + + /// Maximum failures before lockout (used to inform callers / error messages). + pub fn max_failures(&self) -> u32 { + self.max_failures + } + + /// Lockout duration in seconds. + pub fn lockout_secs(&self) -> u64 { + self.lockout_secs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_login_under_threshold() { + let svc = LoginLockoutService::new(3, 60, 100); + assert!(svc.check("alice").is_ok()); + svc.record_failure("alice"); + svc.record_failure("alice"); + // 2 failures — still under threshold + assert!(svc.check("alice").is_ok()); + } + + #[test] + fn locks_after_threshold() { + let svc = LoginLockoutService::new(3, 60, 100); + svc.record_failure("bob"); + svc.record_failure("bob"); + svc.record_failure("bob"); + assert!(svc.check("bob").is_err()); + } + + #[test] + fn resets_on_success() { + let svc = LoginLockoutService::new(3, 60, 100); + svc.record_failure("carol"); + svc.record_failure("carol"); + svc.record_success("carol"); + // Counter reset — should be allowed again + assert!(svc.check("carol").is_ok()); + svc.record_failure("carol"); // starts over at 1 + assert!(svc.check("carol").is_ok()); + } + + #[test] + fn case_insensitive() { + let svc = LoginLockoutService::new(2, 60, 100); + svc.record_failure("Dave"); + svc.record_failure("dave"); + assert!(svc.check("DAVE").is_err()); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 92c347fa..37c3dde9 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -1,11 +1,11 @@ pub mod chunked_upload_service; pub mod compression_service; pub mod dedup_service; -pub mod login_lockout_service; pub mod file_content_cache; pub mod file_system_i18n_service; pub mod image_transcode_service; pub mod jwt_service; +pub mod login_lockout_service; pub mod oidc_service; pub mod password_hasher; pub mod path_resolver_service; diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index 08521405..d7f81896 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -1,205 +1,219 @@ -//! Single-query WebDAV path resolver. -//! -//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`) -//! with a single `UNION ALL` query that returns the first match. PostgreSQL's -//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches -//! the file branch is never executed. - -use sqlx::PgPool; -use std::sync::Arc; - -use crate::application::dtos::display_helpers::{ - category_for, format_file_size, icon_class_for, icon_special_class_for, -}; -use crate::application::dtos::file_dto::FileDto; -use crate::application::dtos::folder_dto::FolderDto; -use crate::common::errors::DomainError; - -/// Result of resolving a WebDAV path — either a folder or a file. -#[derive(Debug, Clone)] -pub enum ResolvedResource { - Folder(FolderDto), - File(FileDto), -} - -/// Resolves a WebDAV path to a folder or file in a single SQL round-trip. -pub struct PathResolverService { - pool: Arc, -} - -impl PathResolverService { - pub fn new(pool: Arc) -> Self { - Self { pool } - } - - /// Resolve `path` (without leading `/`) to either a folder or a file. - /// - /// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated - /// first, and PG short-circuits if it produces a row. - pub async fn resolve_path(&self, path: &str) -> Result { - let path = path.trim_start_matches('/').trim_end_matches('/'); - if path.is_empty() { - return Err(DomainError::not_found("Resource", "empty path")); - } - - // Split into folder_path + filename for the file branch - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - let filename = segments[segments.len() - 1]; - let folder_path = if segments.len() > 1 { - segments[..segments.len() - 1].join("/") - } else { - String::new() - }; - - // Single round-trip: folder branch ∪ file branch, LIMIT 1. - // Column order: resource_type, id, name, path, parent_id, user_id, - // created_at, modified_at, size, mime_type, folder_id - let row = sqlx::query_as::<_, ( - String, // resource_type - String, // id - String, // name - String, // path - Option, // parent_id (folder) / NULL (file) - Option, // user_id - i64, // created_at epoch - i64, // modified_at epoch - Option, // size (NULL for folder) - Option, // mime_type (NULL for folder) - Option, // folder_id (NULL for folder) - )>( - r#" - SELECT resource_type, id, name, path, parent_id, user_id, - created_at, modified_at, size, mime_type, folder_id - FROM ( - SELECT 'folder'::text AS resource_type, - fo.id::text, - fo.name, - fo.path, - fo.parent_id::text, - fo.user_id::text, - EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, - EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, - NULL::bigint AS size, - NULL::text AS mime_type, - NULL::text AS folder_id - FROM storage.folders fo - WHERE fo.path = $1 AND NOT fo.is_trashed - - UNION ALL - - SELECT 'file'::text AS resource_type, - fi.id::text, - fi.name, - CASE - WHEN fo.path IS NOT NULL AND fo.path != '' - THEN fo.path || '/' || fi.name - ELSE fi.name - END AS path, - NULL::text AS parent_id, - fi.user_id::text, - EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, - EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, - fi.size, - fi.mime_type, - fi.folder_id::text - FROM storage.files fi - LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fi.name = $2 - AND ( - ($3 = '' AND fi.folder_id IS NULL) - OR fo.path = $3 - ) - AND NOT fi.is_trashed - ) sub - LIMIT 1 - "#, - ) - .bind(path) // $1 — full path for folder lookup - .bind(filename) // $2 — filename for file lookup - .bind(&folder_path) // $3 — parent folder path for file lookup - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))? - .ok_or_else(|| DomainError::not_found("Resource", path))?; - - let (resource_type, id, name, res_path, parent_id, user_id, - created_at, modified_at, size, mime_type, folder_id) = row; - - match resource_type.as_str() { - "folder" => Ok(ResolvedResource::Folder(FolderDto { - id, - name: name.clone(), - path: res_path, - parent_id, - owner_id: user_id, - created_at: created_at as u64, - modified_at: modified_at as u64, - is_root: false, - icon_class: "fas fa-folder".to_string(), - icon_special_class: "folder-icon".to_string(), - category: "Folder".to_string(), - })), - _ => { - let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); - let sz = size.unwrap_or(0) as u64; - Ok(ResolvedResource::File(FileDto { - id, - name: name.clone(), - path: res_path, - size: sz, - mime_type: mime.clone(), - folder_id, - created_at: created_at as u64, - modified_at: modified_at as u64, - icon_class: icon_class_for(&name, &mime).to_string(), - icon_special_class: icon_special_class_for(&name, &mime).to_string(), - category: category_for(&name, &mime).to_string(), - size_formatted: format_file_size(sz), - owner_id: user_id, - })) - } - } - } - - /// Check whether *any* resource (folder or file) exists at the given path. - /// - /// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO. - pub async fn exists(&self, path: &str) -> Result { - let path = path.trim_start_matches('/').trim_end_matches('/'); - if path.is_empty() { - return Ok(false); - } - - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - let filename = segments[segments.len() - 1]; - let folder_path = if segments.len() > 1 { - segments[..segments.len() - 1].join("/") - } else { - String::new() - }; - - let exists = sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS( - SELECT 1 FROM storage.folders - WHERE path = $1 AND NOT is_trashed - ) OR EXISTS( - SELECT 1 - FROM storage.files fi - LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fi.name = $2 - AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3) - AND NOT fi.is_trashed - ) - "#, - ) - .bind(path) - .bind(filename) - .bind(&folder_path) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?; - - Ok(exists) - } -} +//! Single-query WebDAV path resolver. +//! +//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`) +//! with a single `UNION ALL` query that returns the first match. PostgreSQL's +//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches +//! the file branch is never executed. + +use sqlx::PgPool; +use std::sync::Arc; + +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::common::errors::DomainError; + +/// Result of resolving a WebDAV path — either a folder or a file. +#[derive(Debug, Clone)] +pub enum ResolvedResource { + Folder(FolderDto), + File(FileDto), +} + +/// Resolves a WebDAV path to a folder or file in a single SQL round-trip. +pub struct PathResolverService { + pool: Arc, +} + +impl PathResolverService { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + /// Resolve `path` (without leading `/`) to either a folder or a file. + /// + /// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated + /// first, and PG short-circuits if it produces a row. + pub async fn resolve_path(&self, path: &str) -> Result { + let path = path.trim_start_matches('/').trim_end_matches('/'); + if path.is_empty() { + return Err(DomainError::not_found("Resource", "empty path")); + } + + // Split into folder_path + filename for the file branch + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let filename = segments[segments.len() - 1]; + let folder_path = if segments.len() > 1 { + segments[..segments.len() - 1].join("/") + } else { + String::new() + }; + + // Single round-trip: folder branch ∪ file branch, LIMIT 1. + // Column order: resource_type, id, name, path, parent_id, user_id, + // created_at, modified_at, size, mime_type, folder_id + let row = sqlx::query_as::< + _, + ( + String, // resource_type + String, // id + String, // name + String, // path + Option, // parent_id (folder) / NULL (file) + Option, // user_id + i64, // created_at epoch + i64, // modified_at epoch + Option, // size (NULL for folder) + Option, // mime_type (NULL for folder) + Option, // folder_id (NULL for folder) + ), + >( + r#" + SELECT resource_type, id, name, path, parent_id, user_id, + created_at, modified_at, size, mime_type, folder_id + FROM ( + SELECT 'folder'::text AS resource_type, + fo.id::text, + fo.name, + fo.path, + fo.parent_id::text, + fo.user_id::text, + EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, + EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, + NULL::bigint AS size, + NULL::text AS mime_type, + NULL::text AS folder_id + FROM storage.folders fo + WHERE fo.path = $1 AND NOT fo.is_trashed + + UNION ALL + + SELECT 'file'::text AS resource_type, + fi.id::text, + fi.name, + CASE + WHEN fo.path IS NOT NULL AND fo.path != '' + THEN fo.path || '/' || fi.name + ELSE fi.name + END AS path, + NULL::text AS parent_id, + fi.user_id::text, + EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, + EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, + fi.size, + fi.mime_type, + fi.folder_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.name = $2 + AND ( + ($3 = '' AND fi.folder_id IS NULL) + OR fo.path = $3 + ) + AND NOT fi.is_trashed + ) sub + LIMIT 1 + "#, + ) + .bind(path) // $1 — full path for folder lookup + .bind(filename) // $2 — filename for file lookup + .bind(&folder_path) // $3 — parent folder path for file lookup + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))? + .ok_or_else(|| DomainError::not_found("Resource", path))?; + + let ( + resource_type, + id, + name, + res_path, + parent_id, + user_id, + created_at, + modified_at, + size, + mime_type, + folder_id, + ) = row; + + match resource_type.as_str() { + "folder" => Ok(ResolvedResource::Folder(FolderDto { + id, + name: name.clone(), + path: res_path, + parent_id, + owner_id: user_id, + created_at: created_at as u64, + modified_at: modified_at as u64, + is_root: false, + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + category: "Folder".to_string(), + })), + _ => { + let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); + let sz = size.unwrap_or(0) as u64; + Ok(ResolvedResource::File(FileDto { + id, + name: name.clone(), + path: res_path, + size: sz, + mime_type: mime.clone(), + folder_id, + created_at: created_at as u64, + modified_at: modified_at as u64, + icon_class: icon_class_for(&name, &mime).to_string(), + icon_special_class: icon_special_class_for(&name, &mime).to_string(), + category: category_for(&name, &mime).to_string(), + size_formatted: format_file_size(sz), + owner_id: user_id, + })) + } + } + } + + /// Check whether *any* resource (folder or file) exists at the given path. + /// + /// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO. + pub async fn exists(&self, path: &str) -> Result { + let path = path.trim_start_matches('/').trim_end_matches('/'); + if path.is_empty() { + return Ok(false); + } + + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let filename = segments[segments.len() - 1]; + let folder_path = if segments.len() > 1 { + segments[..segments.len() - 1].join("/") + } else { + String::new() + }; + + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM storage.folders + WHERE path = $1 AND NOT is_trashed + ) OR EXISTS( + SELECT 1 + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.name = $2 + AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3) + AND NOT fi.is_trashed + ) + "#, + ) + .bind(path) + .bind(filename) + .bind(&folder_path) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?; + + Ok(exists) + } +} diff --git a/src/interfaces/api/cookie_auth.rs b/src/interfaces/api/cookie_auth.rs index 4fd3b7cc..d144c4f1 100644 --- a/src/interfaces/api/cookie_auth.rs +++ b/src/interfaces/api/cookie_auth.rs @@ -1,148 +1,140 @@ -//! HttpOnly cookie helpers for secure token transport. -//! -//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that -//! browser-based JavaScript cannot read them (mitigates XSS token theft). -//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var -//! (default: auto-detect from `OXICLOUD_BASE_URL`). -//! -//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set -//! alongside the auth cookies. The frontend must read it and echo its -//! value back as `X-CSRF-Token` on every state-changing request. -//! A middleware (`csrf_middleware`) validates the match. -//! -//! DAV clients continue to use `Authorization: Basic` with app passwords -//! and are completely unaffected by this mechanism. - -use axum::http::header::SET_COOKIE; -use axum::http::{HeaderMap, HeaderValue}; - -/// Cookie name for the JWT access token. -pub const ACCESS_COOKIE: &str = "oxicloud_access"; -/// Cookie name for the opaque refresh token. -pub const REFRESH_COOKIE: &str = "oxicloud_refresh"; -/// Cookie name for the CSRF double-submit token (readable by JS). -pub const CSRF_COOKIE: &str = "oxicloud_csrf"; -/// Header the frontend must send with the CSRF token value. -pub const CSRF_HEADER: &str = "x-csrf-token"; - -/// Whether the `Secure` flag should be set on cookies. -/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`) -/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`. -fn cookie_secure() -> bool { - if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") { - return v == "true" || v == "1"; - } - // Auto-detect from base URL - std::env::var("OXICLOUD_BASE_URL") - .map(|u| u.starts_with("https")) - .unwrap_or(false) -} - -/// Build a `Set-Cookie` header value. -fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String { - let secure = if cookie_secure() { "; Secure" } else { "" }; - format!( - "{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}", - ) -} - -/// Append `Set-Cookie` headers for both access and refresh tokens. -/// -/// The access cookie covers all paths (`/`) because the API lives under -/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc. -/// -/// The refresh cookie is restricted to `/api/auth` so it is only sent -/// when the client explicitly calls the refresh or logout endpoints. -pub fn append_auth_cookies( - headers: &mut HeaderMap, - access_token: &str, - refresh_token: &str, - access_expiry_secs: i64, - refresh_expiry_secs: i64, -) { - if let Ok(val) = HeaderValue::from_str(&build_cookie( - ACCESS_COOKIE, - access_token, - "/", - access_expiry_secs, - )) { - headers.append(SET_COOKIE, val); - } - if let Ok(val) = HeaderValue::from_str(&build_cookie( - REFRESH_COOKIE, - refresh_token, - "/api/auth", - refresh_expiry_secs, - )) { - headers.append(SET_COOKIE, val); - } -} - -/// Append `Set-Cookie` headers that immediately expire both auth cookies, -/// effectively logging the user out on the browser side. -pub fn append_clear_cookies(headers: &mut HeaderMap) { - for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] { - let secure = if cookie_secure() { "; Secure" } else { "" }; - let val = format!( - "{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}", - ); - if let Ok(hv) = HeaderValue::from_str(&val) { - headers.append(SET_COOKIE, hv); - } - } -} - -/// Extract a named cookie value from the `Cookie` request header. -pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option { - let cookie_header = headers.get(axum::http::header::COOKIE)?; - let cookie_str = cookie_header.to_str().ok()?; - - for pair in cookie_str.split(';') { - let pair = pair.trim(); - if let Some(val) = pair.strip_prefix(name) { - let val = val.strip_prefix('=')?; - if !val.is_empty() { - return Some(val.to_string()); - } - } - } - None -} - -// ──────────────────────────────────────────────────────────── -// CSRF double-submit cookie helpers -// ──────────────────────────────────────────────────────────── - -/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like). -pub fn generate_csrf_token() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it -/// via `document.cookie` and echo it back in the `X-CSRF-Token` header. -fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String { - let secure = if cookie_secure() { "; Secure" } else { "" }; - format!( - "{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}", - ) -} - -/// Append a CSRF double-submit cookie alongside the auth cookies. -/// Should be called in every endpoint that also sets auth cookies. -pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) { - let token = generate_csrf_token(); - if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) { - headers.append(SET_COOKIE, val); - } -} - -/// Clear the CSRF cookie (on logout). -pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) { - let secure = if cookie_secure() { "; Secure" } else { "" }; - let val = format!( - "{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}", - ); - if let Ok(hv) = HeaderValue::from_str(&val) { - headers.append(SET_COOKIE, hv); - } -} +//! HttpOnly cookie helpers for secure token transport. +//! +//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that +//! browser-based JavaScript cannot read them (mitigates XSS token theft). +//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var +//! (default: auto-detect from `OXICLOUD_BASE_URL`). +//! +//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set +//! alongside the auth cookies. The frontend must read it and echo its +//! value back as `X-CSRF-Token` on every state-changing request. +//! A middleware (`csrf_middleware`) validates the match. +//! +//! DAV clients continue to use `Authorization: Basic` with app passwords +//! and are completely unaffected by this mechanism. + +use axum::http::header::SET_COOKIE; +use axum::http::{HeaderMap, HeaderValue}; + +/// Cookie name for the JWT access token. +pub const ACCESS_COOKIE: &str = "oxicloud_access"; +/// Cookie name for the opaque refresh token. +pub const REFRESH_COOKIE: &str = "oxicloud_refresh"; +/// Cookie name for the CSRF double-submit token (readable by JS). +pub const CSRF_COOKIE: &str = "oxicloud_csrf"; +/// Header the frontend must send with the CSRF token value. +pub const CSRF_HEADER: &str = "x-csrf-token"; + +/// Whether the `Secure` flag should be set on cookies. +/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`) +/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`. +fn cookie_secure() -> bool { + if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") { + return v == "true" || v == "1"; + } + // Auto-detect from base URL + std::env::var("OXICLOUD_BASE_URL") + .map(|u| u.starts_with("https")) + .unwrap_or(false) +} + +/// Build a `Set-Cookie` header value. +fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String { + let secure = if cookie_secure() { "; Secure" } else { "" }; + format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",) +} + +/// Append `Set-Cookie` headers for both access and refresh tokens. +/// +/// The access cookie covers all paths (`/`) because the API lives under +/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc. +/// +/// The refresh cookie is restricted to `/api/auth` so it is only sent +/// when the client explicitly calls the refresh or logout endpoints. +pub fn append_auth_cookies( + headers: &mut HeaderMap, + access_token: &str, + refresh_token: &str, + access_expiry_secs: i64, + refresh_expiry_secs: i64, +) { + if let Ok(val) = HeaderValue::from_str(&build_cookie( + ACCESS_COOKIE, + access_token, + "/", + access_expiry_secs, + )) { + headers.append(SET_COOKIE, val); + } + if let Ok(val) = HeaderValue::from_str(&build_cookie( + REFRESH_COOKIE, + refresh_token, + "/api/auth", + refresh_expiry_secs, + )) { + headers.append(SET_COOKIE, val); + } +} + +/// Append `Set-Cookie` headers that immediately expire both auth cookies, +/// effectively logging the user out on the browser side. +pub fn append_clear_cookies(headers: &mut HeaderMap) { + for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] { + let secure = if cookie_secure() { "; Secure" } else { "" }; + let val = format!("{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",); + if let Ok(hv) = HeaderValue::from_str(&val) { + headers.append(SET_COOKIE, hv); + } + } +} + +/// Extract a named cookie value from the `Cookie` request header. +pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option { + let cookie_header = headers.get(axum::http::header::COOKIE)?; + let cookie_str = cookie_header.to_str().ok()?; + + for pair in cookie_str.split(';') { + let pair = pair.trim(); + if let Some(val) = pair.strip_prefix(name) { + let val = val.strip_prefix('=')?; + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + +// ──────────────────────────────────────────────────────────── +// CSRF double-submit cookie helpers +// ──────────────────────────────────────────────────────────── + +/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like). +pub fn generate_csrf_token() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it +/// via `document.cookie` and echo it back in the `X-CSRF-Token` header. +fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String { + let secure = if cookie_secure() { "; Secure" } else { "" }; + format!("{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",) +} + +/// Append a CSRF double-submit cookie alongside the auth cookies. +/// Should be called in every endpoint that also sets auth cookies. +pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) { + let token = generate_csrf_token(); + if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) { + headers.append(SET_COOKIE, val); + } +} + +/// Clear the CSRF cookie (on logout). +pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) { + let secure = if cookie_secure() { "; Secure" } else { "" }; + let val = format!("{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",); + if let Ok(hv) = HeaderValue::from_str(&val) { + headers.append(SET_COOKIE, hv); + } +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index df0b9894..72f49e83 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -197,10 +197,7 @@ async fn login( auth_response.expires_in, state.core.config.auth.refresh_token_expiry_secs, ); - cookie_auth::append_csrf_cookie( - response.headers_mut(), - auth_response.expires_in, - ); + cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); Ok(response) } Err(err) => { @@ -253,10 +250,7 @@ async fn refresh_token( auth_response.expires_in, state.core.config.auth.refresh_token_expiry_secs, ); - cookie_auth::append_csrf_cookie( - response.headers_mut(), - auth_response.expires_in, - ); + cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); Ok(response) } @@ -522,9 +516,6 @@ async fn oidc_exchange( auth_response.expires_in, state.core.config.auth.refresh_token_expiry_secs, ); - cookie_auth::append_csrf_cookie( - response.headers_mut(), - auth_response.expires_in, - ); + cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); Ok(response) } diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index cf55caa9..2191ad0b 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -52,11 +52,10 @@ pub fn caldav_routes() -> Router> { /// Creates RFC 6764 well-known discovery routes. /// These are public (no auth) and simply redirect to the CalDAV root. pub fn well_known_routes() -> Router> { - Router::new() - .route( - "/.well-known/caldav", - axum::routing::any(handle_well_known_caldav), - ) + Router::new().route( + "/.well-known/caldav", + axum::routing::any(handle_well_known_caldav), + ) } async fn handle_well_known_caldav() -> Response { @@ -114,13 +113,9 @@ fn extract_caldav_path(uri_path: &str) -> String { } else if uri_path.ends_with("/caldav") { "" } else { - uri_path - .trim_start_matches('/') - .trim_end_matches('/') + uri_path.trim_start_matches('/').trim_end_matches('/') }; - percent_decode_str(encoded) - .decode_utf8_lossy() - .into_owned() + percent_decode_str(encoded).decode_utf8_lossy().into_owned() } // ─── Helper: extract user from request ─────────────────────────────── @@ -198,9 +193,7 @@ async fn handle_propfind( calendar_service .list_my_calendars(&user.id) .await - .map_err(|e| { - AppError::internal_error(format!("Failed to list calendars: {}", e)) - })? + .map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))? }; let base_href = "/caldav/"; @@ -221,9 +214,7 @@ async fn handle_propfind( .unwrap()) } else if path.starts_with("principals/") || path == "principals" { // Principal resource — return user principal properties - let username = path - .strip_prefix("principals/") - .unwrap_or(&user.username); + let username = path.strip_prefix("principals/").unwrap_or(&user.username); let username = if username.is_empty() { &user.username } else { @@ -255,9 +246,7 @@ async fn handle_propfind( if parts.len() == 1 { // Single path segment: try as calendar ID first, fall back to user home - let calendar_result = calendar_service - .get_calendar(first_segment, &user.id) - .await; + let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await; if let Ok(calendar) = calendar_result { // Valid calendar ID — return calendar collection @@ -281,9 +270,7 @@ async fn handle_propfind( base_href, &depth, ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate XML: {}", e)) - })?; + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) @@ -293,12 +280,13 @@ async fn handle_propfind( } else { // Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/) // List all calendars for this user - let calendars = calendar_service - .list_my_calendars(&user.id) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to list calendars: {}", e)) - })?; + let calendars = + calendar_service + .list_my_calendars(&user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to list calendars: {}", e)) + })?; let base_href = &format!("/caldav/{}/", first_segment); let mut response_body = Vec::new(); @@ -309,9 +297,7 @@ async fn handle_propfind( &propfind_request, base_href, ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate XML: {}", e)) - })?; + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) @@ -324,9 +310,7 @@ async fn handle_propfind( let rest = parts[1]; // Check if first_segment is a valid calendar ID - let calendar_result = calendar_service - .get_calendar(first_segment, &user.id) - .await; + let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await; let (calendar_id, event_path) = if calendar_result.is_ok() { // first_segment is a calendar ID, rest is event path @@ -341,9 +325,7 @@ async fn handle_propfind( let cal = calendar_service .get_calendar(sub_parts[0], &user.id) .await - .map_err(|e| { - AppError::not_found(format!("Calendar not found: {}", e)) - })?; + .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; let events = if depth != "0" { calendar_service @@ -354,8 +336,7 @@ async fn handle_propfind( vec![] }; - let base_href = - &format!("/caldav/{}/{}/", first_segment, sub_parts[0]); + let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]); let mut response_body = Vec::new(); CalDavAdapter::generate_calendar_collection_propfind( @@ -387,16 +368,12 @@ async fn handle_propfind( let events = calendar_service .list_events(calendar_id, None, None, &user.id) .await - .map_err(|e| { - AppError::internal_error(format!("Failed to list events: {}", e)) - })?; + .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; let event = events .iter() .find(|e| e.ical_uid == ical_uid) - .ok_or_else(|| { - AppError::not_found(format!("Event not found: {}", ical_uid)) - })?; + .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; let base_href = &format!("/caldav/{}/", calendar_id); let report_type = CalDavReportType::CalendarMultiget { @@ -411,9 +388,7 @@ async fn handle_propfind( &report_type, base_href, ) - .map_err(|e| { - AppError::internal_error(format!("Failed to generate XML: {}", e)) - })?; + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) @@ -718,7 +693,10 @@ fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarE } /// Writes a VEVENT block directly into `buf` — zero intermediate allocations. -fn write_vevent(buf: &mut String, event: &crate::application::dtos::calendar_dto::CalendarEventDto) { +fn write_vevent( + buf: &mut String, + event: &crate::application::dtos::calendar_dto::CalendarEventDto, +) { let _ = write!( buf, "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n", diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 8f746543..a5685b70 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -99,9 +99,7 @@ fn extract_carddav_path(uri_path: &str) -> String { } else if uri_path.ends_with("/carddav") { "" } else { - uri_path - .trim_start_matches('/') - .trim_end_matches('/') + uri_path.trim_start_matches('/').trim_end_matches('/') }; percent_encoding::percent_decode_str(encoded) .decode_utf8_lossy() diff --git a/src/interfaces/api/handlers/device_auth_handler.rs b/src/interfaces/api/handlers/device_auth_handler.rs index a93864c9..b5af6f7a 100644 --- a/src/interfaces/api/handlers/device_auth_handler.rs +++ b/src/interfaces/api/handlers/device_auth_handler.rs @@ -1,227 +1,225 @@ -//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628). -//! -//! Endpoints: -//! POST /api/auth/device/authorize — Client starts the device flow (public) -//! GET /api/auth/device/verify — Check user_code validity (authenticated) -//! POST /api/auth/device/verify — User approves/denies (authenticated) -//! POST /api/auth/device/token — Client polls for tokens (public) -//! GET /api/auth/device/devices — List user's authorized devices (authenticated) -//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated) - -use axum::{ - Router, - extract::{Json, Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{delete, get, post}, -}; -use std::sync::Arc; - -use crate::application::dtos::device_auth_dto::*; -use crate::application::services::device_auth_service::DeviceAuthService; -use crate::common::di::AppState; -use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::AuthUser; - -/// Create the device auth router. -/// -/// Public endpoints (no auth middleware): authorize, token -/// Protected endpoints (behind auth middleware): verify (GET+POST), devices -pub fn device_auth_public_routes() -> Router> { - Router::new() - // Client-facing endpoints (no auth needed — the client doesn't have tokens yet) - .route("/authorize", post(device_authorize)) - .route("/token", post(device_token)) -} - -pub fn device_auth_protected_routes() -> Router> { - Router::new() - // User-facing endpoints (require valid session) - .route("/verify", get(device_verify_info)) - .route("/verify", post(device_verify_action)) - .route("/devices", get(list_devices)) - .route("/devices/{id}", delete(revoke_device)) -} - -// ============================================================================ -// POST /api/auth/device/authorize — Client initiates the device flow -// ============================================================================ - -/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }` -/// Server returns: device_code, user_code, verification_uri, etc. -async fn device_authorize( - State(state): State>, - Json(body): Json, -) -> Result { - let device_service = get_device_service(&state)?; - - let response = device_service.initiate(body).await.map_err(|e| { - tracing::error!("Device authorize failed: {}", e); - AppError::from(e) - })?; - - Ok((StatusCode::OK, Json(response))) -} - -// ============================================================================ -// POST /api/auth/device/token — Client polls for tokens -// ============================================================================ - -/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }` -/// Returns tokens on success, or RFC 8628 error codes while pending. -async fn device_token( - State(state): State>, - Json(body): Json, -) -> Result { - let device_service = match get_device_service(&state) { - Ok(svc) => svc, - Err(e) => return Err(e.into_response()), - }; - - // Validate grant_type if provided (RFC compliance) - if !body.grant_type.is_empty() - && body.grant_type != "urn:ietf:params:oauth:grant-type:device_code" - { - let error_body = serde_json::json!({ - "error": "unsupported_grant_type", - "error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code" - }); - return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response()); - } - - match device_service.poll(&body.device_code).await { - Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()), - Err(poll_err) => { - let status = StatusCode::from_u16(poll_err.http_status()) - .unwrap_or(StatusCode::BAD_REQUEST); - let error_body = serde_json::json!({ - "error": poll_err.error_code(), - "error_description": poll_err.description() - }); - Err((status, Json(error_body)).into_response()) - } - } -} - -// ============================================================================ -// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid -// ============================================================================ - -#[derive(serde::Deserialize)] -pub struct VerifyQuery { - #[serde(default)] - pub code: String, -} - -async fn device_verify_info( - State(state): State>, - _auth_user: AuthUser, - Query(query): Query, -) -> Result { - let device_service = get_device_service(&state)?; - - let info = device_service - .verify_user_code(&query.code) - .await - .map_err(|e| { - tracing::warn!("Device verify lookup failed: {}", e); - AppError::from(e) - })?; - - Ok((StatusCode::OK, Json(info))) -} - -// ============================================================================ -// POST /api/auth/device/verify — User approves or denies -// ============================================================================ - -async fn device_verify_action( - State(state): State>, - auth_user: AuthUser, - Json(body): Json, -) -> Result { - let device_service = get_device_service(&state)?; - - match body.action.to_lowercase().as_str() { - "approve" | "allow" | "accept" => { - device_service - .approve(&body.user_code, &auth_user.id) - .await - .map_err(|e| { - tracing::error!("Device approve failed: {}", e); - AppError::from(e) - })?; - Ok(( - StatusCode::OK, - Json(serde_json::json!({ "status": "approved" })), - )) - } - "deny" | "reject" | "cancel" => { - device_service.deny(&body.user_code).await.map_err(|e| { - tracing::error!("Device deny failed: {}", e); - AppError::from(e) - })?; - Ok(( - StatusCode::OK, - Json(serde_json::json!({ "status": "denied" })), - )) - } - _ => Err(AppError::bad_request( - "action must be 'approve' or 'deny'", - )), - } -} - -// ============================================================================ -// GET /api/auth/device/devices — List user's authorized devices -// ============================================================================ - -async fn list_devices( - State(state): State>, - auth_user: AuthUser, -) -> Result { - let device_service = get_device_service(&state)?; - - let devices = device_service - .list_user_devices(&auth_user.id) - .await - .map_err(|e| { - tracing::error!("List devices failed: {}", e); - AppError::from(e) - })?; - - Ok((StatusCode::OK, Json(devices))) -} - -// ============================================================================ -// DELETE /api/auth/device/devices/{id} — Revoke a device authorization -// ============================================================================ - -async fn revoke_device( - State(state): State>, - auth_user: AuthUser, - Path(device_id): Path, -) -> Result { - let device_service = get_device_service(&state)?; - - device_service - .revoke_device(&device_id, &auth_user.id) - .await - .map_err(|e| { - tracing::error!("Revoke device failed: {}", e); - AppError::from(e) - })?; - - Ok(StatusCode::NO_CONTENT) -} - -// ============================================================================ -// Helper -// ============================================================================ - -fn get_device_service(state: &AppState) -> Result<&Arc, AppError> { - state - .device_auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Device authorization service not configured")) -} +//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628). +//! +//! Endpoints: +//! POST /api/auth/device/authorize — Client starts the device flow (public) +//! GET /api/auth/device/verify — Check user_code validity (authenticated) +//! POST /api/auth/device/verify — User approves/denies (authenticated) +//! POST /api/auth/device/token — Client polls for tokens (public) +//! GET /api/auth/device/devices — List user's authorized devices (authenticated) +//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated) + +use axum::{ + Router, + extract::{Json, Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post}, +}; +use std::sync::Arc; + +use crate::application::dtos::device_auth_dto::*; +use crate::application::services::device_auth_service::DeviceAuthService; +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +/// Create the device auth router. +/// +/// Public endpoints (no auth middleware): authorize, token +/// Protected endpoints (behind auth middleware): verify (GET+POST), devices +pub fn device_auth_public_routes() -> Router> { + Router::new() + // Client-facing endpoints (no auth needed — the client doesn't have tokens yet) + .route("/authorize", post(device_authorize)) + .route("/token", post(device_token)) +} + +pub fn device_auth_protected_routes() -> Router> { + Router::new() + // User-facing endpoints (require valid session) + .route("/verify", get(device_verify_info)) + .route("/verify", post(device_verify_action)) + .route("/devices", get(list_devices)) + .route("/devices/{id}", delete(revoke_device)) +} + +// ============================================================================ +// POST /api/auth/device/authorize — Client initiates the device flow +// ============================================================================ + +/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }` +/// Server returns: device_code, user_code, verification_uri, etc. +async fn device_authorize( + State(state): State>, + Json(body): Json, +) -> Result { + let device_service = get_device_service(&state)?; + + let response = device_service.initiate(body).await.map_err(|e| { + tracing::error!("Device authorize failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(response))) +} + +// ============================================================================ +// POST /api/auth/device/token — Client polls for tokens +// ============================================================================ + +/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }` +/// Returns tokens on success, or RFC 8628 error codes while pending. +async fn device_token( + State(state): State>, + Json(body): Json, +) -> Result { + let device_service = match get_device_service(&state) { + Ok(svc) => svc, + Err(e) => return Err(e.into_response()), + }; + + // Validate grant_type if provided (RFC compliance) + if !body.grant_type.is_empty() + && body.grant_type != "urn:ietf:params:oauth:grant-type:device_code" + { + let error_body = serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code" + }); + return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response()); + } + + match device_service.poll(&body.device_code).await { + Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()), + Err(poll_err) => { + let status = + StatusCode::from_u16(poll_err.http_status()).unwrap_or(StatusCode::BAD_REQUEST); + let error_body = serde_json::json!({ + "error": poll_err.error_code(), + "error_description": poll_err.description() + }); + Err((status, Json(error_body)).into_response()) + } + } +} + +// ============================================================================ +// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid +// ============================================================================ + +#[derive(serde::Deserialize)] +pub struct VerifyQuery { + #[serde(default)] + pub code: String, +} + +async fn device_verify_info( + State(state): State>, + _auth_user: AuthUser, + Query(query): Query, +) -> Result { + let device_service = get_device_service(&state)?; + + let info = device_service + .verify_user_code(&query.code) + .await + .map_err(|e| { + tracing::warn!("Device verify lookup failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(info))) +} + +// ============================================================================ +// POST /api/auth/device/verify — User approves or denies +// ============================================================================ + +async fn device_verify_action( + State(state): State>, + auth_user: AuthUser, + Json(body): Json, +) -> Result { + let device_service = get_device_service(&state)?; + + match body.action.to_lowercase().as_str() { + "approve" | "allow" | "accept" => { + device_service + .approve(&body.user_code, &auth_user.id) + .await + .map_err(|e| { + tracing::error!("Device approve failed: {}", e); + AppError::from(e) + })?; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "status": "approved" })), + )) + } + "deny" | "reject" | "cancel" => { + device_service.deny(&body.user_code).await.map_err(|e| { + tracing::error!("Device deny failed: {}", e); + AppError::from(e) + })?; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "status": "denied" })), + )) + } + _ => Err(AppError::bad_request("action must be 'approve' or 'deny'")), + } +} + +// ============================================================================ +// GET /api/auth/device/devices — List user's authorized devices +// ============================================================================ + +async fn list_devices( + State(state): State>, + auth_user: AuthUser, +) -> Result { + let device_service = get_device_service(&state)?; + + let devices = device_service + .list_user_devices(&auth_user.id) + .await + .map_err(|e| { + tracing::error!("List devices failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(devices))) +} + +// ============================================================================ +// DELETE /api/auth/device/devices/{id} — Revoke a device authorization +// ============================================================================ + +async fn revoke_device( + State(state): State>, + auth_user: AuthUser, + Path(device_id): Path, +) -> Result { + let device_service = get_device_service(&state)?; + + device_service + .revoke_device(&device_id, &auth_user.id) + .await + .map_err(|e| { + tracing::error!("Revoke device failed: {}", e); + AppError::from(e) + })?; + + Ok(StatusCode::NO_CONTENT) +} + +// ============================================================================ +// Helper +// ============================================================================ + +fn get_device_service(state: &AppState) -> Result<&Arc, AppError> { + state + .device_auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Device authorization service not configured")) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 226a80c9..5dc06917 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -2,11 +2,11 @@ pub mod admin_handler; pub mod app_password_handler; pub mod auth_handler; pub mod batch_handler; -pub mod device_auth_handler; pub mod caldav_handler; pub mod carddav_handler; pub mod chunked_upload_handler; pub mod dedup_handler; +pub mod device_auth_handler; pub mod favorites_handler; pub mod file_handler; pub mod folder_handler; diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 7b3479e6..1957bb46 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -28,7 +28,7 @@ use crate::common::di::AppState; use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; -use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use std::sync::Arc; /// Characters that MUST NOT be percent-encoded inside a URI path segment. @@ -115,9 +115,7 @@ fn extract_webdav_path(uri: &axum::http::Uri) -> String { trimmed.trim_end_matches('/') }; // Decode percent-encoded characters (e.g. %20 → space) - percent_decode_str(encoded) - .decode_utf8_lossy() - .into_owned() + percent_decode_str(encoded).decode_utf8_lossy().into_owned() } async fn handle_webdav_methods_root( @@ -324,8 +322,13 @@ async fn handle_propfind( let mut xml_writer = Writer::new(&mut buf); WebDavAdapter::write_multistatus_start(&mut xml_writer) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; - WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href) - .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_file_entry( + &mut xml_writer, + &file, + &propfind_request, + &base_href, + ) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; WebDavAdapter::write_multistatus_end(&mut xml_writer) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -358,8 +361,13 @@ async fn handle_propfind( let mut xml_writer = Writer::new(&mut buf); WebDavAdapter::write_multistatus_start(&mut xml_writer) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; - WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href) - .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_file_entry( + &mut xml_writer, + &file, + &propfind_request, + &base_href, + ) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; WebDavAdapter::write_multistatus_end(&mut xml_writer) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -910,13 +918,17 @@ async fn handle_delete( folder_service .delete_folder(&folder.id, caller_id) .await - .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to delete folder: {}", e)) + })?; } Ok(ResolvedResource::File(file)) => { file_management_service .delete_file(&file.id) .await - .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to delete file: {}", e)) + })?; } Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))), } @@ -1002,8 +1014,14 @@ async fn handle_move( let dest_exists = if let Some(resolver) = &state.path_resolver { resolver.exists(&destination_path).await.unwrap_or(false) } else { - folder_service.get_folder_by_path(&destination_path).await.is_ok() - || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok() + folder_service + .get_folder_by_path(&destination_path) + .await + .is_ok() + || file_retrieval_service + .get_file_by_path(&destination_path) + .await + .is_ok() }; if dest_exists { return Err(AppError::precondition_failed( @@ -1044,7 +1062,9 @@ async fn handle_move( folder.owner_id.as_deref().unwrap_or("webdav"), ) .await - .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to move folder: {}", e)) + })?; if folder.name != dest_folder_name { let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto { @@ -1057,7 +1077,9 @@ async fn handle_move( folder.owner_id.as_deref().unwrap_or("webdav"), ) .await - .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to rename folder: {}", e)) + })?; } } Ok(ResolvedResource::File(file)) => { @@ -1080,16 +1102,25 @@ async fn handle_move( file_management_service .move_file(&file.id, Some(dest_parent_path.to_string())) .await - .map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to move file: {}", e)) + })?; } if file.name != dest_filename { file_management_service .rename_file(&file.id, dest_filename) .await - .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to rename file: {}", e)) + })?; } } - Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))), + Err(_) => { + return Err(AppError::not_found(format!( + "Resource not found: {}", + source_path + ))); + } } } else { // Fallback: legacy double-query path @@ -1137,13 +1168,17 @@ async fn handle_move( folder.owner_id.as_deref().unwrap_or("webdav"), ) .await - .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to rename folder: {}", e)) + })?; } } else { let file = file_retrieval_service .get_file_by_path(&source_path) .await - .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + .map_err(|_e| { + AppError::not_found(format!("Resource not found: {}", source_path)) + })?; let dest_filename = destination_path .split('/') @@ -1170,7 +1205,9 @@ async fn handle_move( file_management_service .rename_file(&file.id, dest_filename) .await - .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to rename file: {}", e)) + })?; } } } @@ -1240,8 +1277,14 @@ async fn handle_copy( let dest_exists = if let Some(resolver) = &state.path_resolver { resolver.exists(&destination_path).await.unwrap_or(false) } else { - folder_service.get_folder_by_path(&destination_path).await.is_ok() - || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok() + folder_service + .get_folder_by_path(&destination_path) + .await + .is_ok() + || file_retrieval_service + .get_file_by_path(&destination_path) + .await + .is_ok() }; if dest_exists { return Err(AppError::precondition_failed( @@ -1296,7 +1339,10 @@ async fn handle_copy( .create_folder(create_dto) .await .map_err(|e| { - AppError::internal_error(format!("Failed to create destination folder: {}", e)) + AppError::internal_error(format!( + "Failed to create destination folder: {}", + e + )) })?; } } @@ -1322,7 +1368,12 @@ async fn handle_copy( .await .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } - Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))), + Err(_) => { + return Err(AppError::not_found(format!( + "Resource not found: {}", + source_path + ))); + } } } else { // Fallback: legacy double-query path @@ -1371,14 +1422,19 @@ async fn handle_copy( .create_folder(create_dto) .await .map_err(|e| { - AppError::internal_error(format!("Failed to create destination folder: {}", e)) + AppError::internal_error(format!( + "Failed to create destination folder: {}", + e + )) })?; } } else { let file = file_retrieval_service .get_file_by_path(&source_path) .await - .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + .map_err(|_e| { + AppError::not_found(format!("Resource not found: {}", source_path)) + })?; let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index e3b664d3..1e6e412f 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -205,10 +205,7 @@ pub async fn auth_middleware( } Err(e) => { tracing::warn!("Bearer token validation failed: {}", e); - return Err(AuthError::InvalidToken(format!( - "Invalid token: {}", - e - ))); + return Err(AuthError::InvalidToken(format!("Invalid token: {}", e))); } } } @@ -273,7 +270,9 @@ pub async fn auth_middleware( { use crate::interfaces::api::cookie_auth; - if let Some(token_str) = cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) { + if let Some(token_str) = + cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) + { if !token_str.is_empty() { tracing::debug!("Processing cookie-based authentication"); @@ -281,10 +280,7 @@ pub async fn auth_middleware( let token_service = &auth_service.token_service; match token_service.validate_token(&token_str) { Ok(claims) => { - tracing::debug!( - "Cookie token validated for user: {}", - claims.username - ); + tracing::debug!("Cookie token validated for user: {}", claims.username); let current_user = CurrentUser { id: claims.sub, username: claims.username, diff --git a/src/interfaces/middleware/csrf.rs b/src/interfaces/middleware/csrf.rs index 6540fe90..dfd49568 100644 --- a/src/interfaces/middleware/csrf.rs +++ b/src/interfaces/middleware/csrf.rs @@ -1,75 +1,73 @@ -//! CSRF double-submit cookie middleware. -//! -//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were -//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must** -//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf` -//! cookie. Requests authenticated via `Bearer` or `Basic` headers are -//! exempt because they are not vulnerable to CSRF — the browser never -//! attaches those automatically. -//! -//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through. - -use axum::{ - extract::Request, - http::{Method, StatusCode}, - middleware::Next, - response::{IntoResponse, Response}, -}; - -use crate::interfaces::api::cookie_auth; -use crate::interfaces::middleware::auth::CookieAuthenticated; - -/// Methods considered safe (no side-effects) — CSRF check is skipped. -const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS]; - -/// Middleware that enforces CSRF protection for cookie-authenticated browser -/// sessions using the **double-submit cookie** pattern. -/// -/// Must be applied **after** `auth_middleware` so that the -/// `CookieAuthenticated` marker is available in extensions. -pub async fn csrf_middleware(request: Request, next: Next) -> Result { - // Safe methods never need CSRF validation. - if SAFE_METHODS.contains(request.method()) { - return Ok(next.run(request).await); - } - - // Only enforce for cookie-authenticated sessions. - let is_cookie_auth = request.extensions().get::().is_some(); - if !is_cookie_auth { - return Ok(next.run(request).await); - } - - // Extract the CSRF token from the cookie. - let cookie_token = cookie_auth::extract_cookie_value( - request.headers(), - cookie_auth::CSRF_COOKIE, - ); - - // Extract the CSRF token from the request header. - let header_token = request - .headers() - .get(cookie_auth::CSRF_HEADER) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - match (cookie_token, header_token) { - (Some(c), Some(h)) if !c.is_empty() && c == h => { - // Tokens match — allow the request through. - Ok(next.run(request).await) - } - _ => { - tracing::warn!( - method = %request.method(), - uri = %request.uri(), - "CSRF validation failed: missing or mismatched token" - ); - Err(( - StatusCode::FORBIDDEN, - axum::Json(serde_json::json!({ - "error": "CSRF token missing or invalid" - })), - ) - .into_response()) - } - } -} +//! CSRF double-submit cookie middleware. +//! +//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were +//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must** +//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf` +//! cookie. Requests authenticated via `Bearer` or `Basic` headers are +//! exempt because they are not vulnerable to CSRF — the browser never +//! attaches those automatically. +//! +//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through. + +use axum::{ + extract::Request, + http::{Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::interfaces::api::cookie_auth; +use crate::interfaces::middleware::auth::CookieAuthenticated; + +/// Methods considered safe (no side-effects) — CSRF check is skipped. +const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS]; + +/// Middleware that enforces CSRF protection for cookie-authenticated browser +/// sessions using the **double-submit cookie** pattern. +/// +/// Must be applied **after** `auth_middleware` so that the +/// `CookieAuthenticated` marker is available in extensions. +pub async fn csrf_middleware(request: Request, next: Next) -> Result { + // Safe methods never need CSRF validation. + if SAFE_METHODS.contains(request.method()) { + return Ok(next.run(request).await); + } + + // Only enforce for cookie-authenticated sessions. + let is_cookie_auth = request.extensions().get::().is_some(); + if !is_cookie_auth { + return Ok(next.run(request).await); + } + + // Extract the CSRF token from the cookie. + let cookie_token = + cookie_auth::extract_cookie_value(request.headers(), cookie_auth::CSRF_COOKIE); + + // Extract the CSRF token from the request header. + let header_token = request + .headers() + .get(cookie_auth::CSRF_HEADER) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match (cookie_token, header_token) { + (Some(c), Some(h)) if !c.is_empty() && c == h => { + // Tokens match — allow the request through. + Ok(next.run(request).await) + } + _ => { + tracing::warn!( + method = %request.method(), + uri = %request.uri(), + "CSRF validation failed: missing or mismatched token" + ); + Err(( + StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "error": "CSRF token missing or invalid" + })), + ) + .into_response()) + } + } +} diff --git a/src/interfaces/middleware/rate_limit.rs b/src/interfaces/middleware/rate_limit.rs index 84413516..003738bc 100644 --- a/src/interfaces/middleware/rate_limit.rs +++ b/src/interfaces/middleware/rate_limit.rs @@ -1,202 +1,196 @@ -//! IP-based rate limiting middleware for authentication endpoints. -//! -//! Uses `moka` TTL caches (already a project dependency) to track request -//! counts per client IP. Each protected endpoint group gets its own -//! [`RateLimiter`] instance with independently tuneable limits. -//! -//! The middleware extracts the client IP from (in order): -//! 1. `X-Forwarded-For` header (first entry — set by reverse proxies) -//! 2. `X-Real-Ip` header -//! 3. The TCP peer address from the connection info -//! -//! When the limit is exceeded a `429 Too Many Requests` response is returned -//! with a `Retry-After` header indicating how many seconds to wait. - -use axum::{ - extract::ConnectInfo, - http::{HeaderValue, Request, StatusCode}, - middleware::Next, - response::{IntoResponse, Response}, -}; -use moka::sync::Cache; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -/// A simple sliding-window counter keyed by IP address. -/// -/// Each key lives for `window` seconds; every request increments the counter. -/// Once the counter reaches `max_requests` the request is rejected. -#[derive(Clone)] -pub struct RateLimiter { - /// Maps `IP -> request_count` with automatic TTL expiration. - cache: Cache, - /// Maximum requests allowed within the window. - max_requests: u32, - /// Window duration in seconds (also used for `Retry-After`). - window_secs: u64, -} - -impl RateLimiter { - /// Create a new rate limiter. - /// - /// * `max_requests` — ceiling per IP within the window - /// * `window_secs` — sliding window duration - /// * `max_entries` — upper bound on tracked IPs (evicts LRU when exceeded) - pub fn new(max_requests: u32, window_secs: u64, max_entries: u64) -> Self { - let cache = Cache::builder() - .time_to_live(Duration::from_secs(window_secs)) - .max_capacity(max_entries) - .build(); - Self { - cache, - max_requests, - window_secs, - } - } - - /// Check whether the IP is allowed. Returns `Ok(current_count)` or - /// `Err(StatusCode::TOO_MANY_REQUESTS)`. - pub fn check_and_increment(&self, ip: &str) -> Result { - let key = ip.to_string(); - // moka's entry API lets us atomically read-modify-write. - // On first access the entry is inserted with count = 1 and the TTL - // starts. Subsequent accesses within the window increment the count. - let count = self - .cache - .entry(key) - .or_insert_with(|| 0) - .into_value() - + 1; - - // Write back the incremented value. Because `or_insert_with` returns - // the *existing* value when the key was already present, we must always - // re-insert so the counter actually advances. The TTL of the **first** - // insert still governs eviction because moka uses insert-time TTL. - // However, on re-insert moka resets the TTL — for rate limiting this - // is fine because it means the window "slides" forward on activity. - self.cache - .insert(ip.to_string(), count); - - if count > self.max_requests { - Err(()) - } else { - Ok(count) - } - } - - /// Seconds the client should wait before retrying. - pub fn retry_after(&self) -> u64 { - self.window_secs - } -} - -// ─── Axum middleware factories ────────────────────────────────────────────── - -/// Extract the most-likely real client IP from headers / connection info. -pub fn extract_client_ip(req: &Request) -> String { - let headers = req.headers(); - - // 1. X-Forwarded-For (first entry — closest to the client) - if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { - if let Some(first) = xff.split(',').next() { - let ip = first.trim(); - if !ip.is_empty() { - return ip.to_string(); - } - } - } - - // 2. X-Real-Ip - if let Some(xri) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { - let ip = xri.trim(); - if !ip.is_empty() { - return ip.to_string(); - } - } - - // 3. TCP peer (ConnectInfo extension set by axum::serve) - if let Some(addr) = req.extensions().get::>() { - return addr.0.ip().to_string(); - } - - // Fallback — should never happen behind axum::serve - "unknown".to_string() -} - -/// Build a rate-limit response with the standard `Retry-After` header. -fn too_many_requests(retry_after: u64) -> Response { - let body = serde_json::json!({ - "error": "Too many requests", - "retry_after_secs": retry_after, - }); - let mut resp = (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response(); - if let Ok(val) = HeaderValue::from_str(&retry_after.to_string()) { - resp.headers_mut().insert("retry-after", val); - } - resp -} - -/// Axum middleware: rate-limit login attempts. -/// -/// Inject via: -/// ```ignore -/// .layer(axum::middleware::from_fn_with_state(limiter, rate_limit_login)) -/// ``` -pub async fn rate_limit_login( - State(limiter): axum::extract::State>, - req: Request, - next: Next, -) -> Response { - let ip = extract_client_ip(&req); - match limiter.check_and_increment(&ip) { - Ok(_) => next.run(req).await, - Err(()) => { - tracing::warn!( - ip = %ip, - "Rate limit exceeded on login endpoint" - ); - too_many_requests(limiter.retry_after()) - } - } -} - -/// Axum middleware: rate-limit registration attempts. -pub async fn rate_limit_register( - State(limiter): axum::extract::State>, - req: Request, - next: Next, -) -> Response { - let ip = extract_client_ip(&req); - match limiter.check_and_increment(&ip) { - Ok(_) => next.run(req).await, - Err(()) => { - tracing::warn!( - ip = %ip, - "Rate limit exceeded on register endpoint" - ); - too_many_requests(limiter.retry_after()) - } - } -} - -/// Axum middleware: rate-limit token refresh attempts. -pub async fn rate_limit_refresh( - State(limiter): axum::extract::State>, - req: Request, - next: Next, -) -> Response { - let ip = extract_client_ip(&req); - match limiter.check_and_increment(&ip) { - Ok(_) => next.run(req).await, - Err(()) => { - tracing::warn!( - ip = %ip, - "Rate limit exceeded on refresh endpoint" - ); - too_many_requests(limiter.retry_after()) - } - } -} - -use axum::extract::State; +//! IP-based rate limiting middleware for authentication endpoints. +//! +//! Uses `moka` TTL caches (already a project dependency) to track request +//! counts per client IP. Each protected endpoint group gets its own +//! [`RateLimiter`] instance with independently tuneable limits. +//! +//! The middleware extracts the client IP from (in order): +//! 1. `X-Forwarded-For` header (first entry — set by reverse proxies) +//! 2. `X-Real-Ip` header +//! 3. The TCP peer address from the connection info +//! +//! When the limit is exceeded a `429 Too Many Requests` response is returned +//! with a `Retry-After` header indicating how many seconds to wait. + +use axum::{ + extract::ConnectInfo, + http::{HeaderValue, Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use moka::sync::Cache; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +/// A simple sliding-window counter keyed by IP address. +/// +/// Each key lives for `window` seconds; every request increments the counter. +/// Once the counter reaches `max_requests` the request is rejected. +#[derive(Clone)] +pub struct RateLimiter { + /// Maps `IP -> request_count` with automatic TTL expiration. + cache: Cache, + /// Maximum requests allowed within the window. + max_requests: u32, + /// Window duration in seconds (also used for `Retry-After`). + window_secs: u64, +} + +impl RateLimiter { + /// Create a new rate limiter. + /// + /// * `max_requests` — ceiling per IP within the window + /// * `window_secs` — sliding window duration + /// * `max_entries` — upper bound on tracked IPs (evicts LRU when exceeded) + pub fn new(max_requests: u32, window_secs: u64, max_entries: u64) -> Self { + let cache = Cache::builder() + .time_to_live(Duration::from_secs(window_secs)) + .max_capacity(max_entries) + .build(); + Self { + cache, + max_requests, + window_secs, + } + } + + /// Check whether the IP is allowed. Returns `Ok(current_count)` or + /// `Err(StatusCode::TOO_MANY_REQUESTS)`. + pub fn check_and_increment(&self, ip: &str) -> Result { + let key = ip.to_string(); + // moka's entry API lets us atomically read-modify-write. + // On first access the entry is inserted with count = 1 and the TTL + // starts. Subsequent accesses within the window increment the count. + let count = self.cache.entry(key).or_insert_with(|| 0).into_value() + 1; + + // Write back the incremented value. Because `or_insert_with` returns + // the *existing* value when the key was already present, we must always + // re-insert so the counter actually advances. The TTL of the **first** + // insert still governs eviction because moka uses insert-time TTL. + // However, on re-insert moka resets the TTL — for rate limiting this + // is fine because it means the window "slides" forward on activity. + self.cache.insert(ip.to_string(), count); + + if count > self.max_requests { + Err(()) + } else { + Ok(count) + } + } + + /// Seconds the client should wait before retrying. + pub fn retry_after(&self) -> u64 { + self.window_secs + } +} + +// ─── Axum middleware factories ────────────────────────────────────────────── + +/// Extract the most-likely real client IP from headers / connection info. +pub fn extract_client_ip(req: &Request) -> String { + let headers = req.headers(); + + // 1. X-Forwarded-For (first entry — closest to the client) + if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + if let Some(first) = xff.split(',').next() { + let ip = first.trim(); + if !ip.is_empty() { + return ip.to_string(); + } + } + } + + // 2. X-Real-Ip + if let Some(xri) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { + let ip = xri.trim(); + if !ip.is_empty() { + return ip.to_string(); + } + } + + // 3. TCP peer (ConnectInfo extension set by axum::serve) + if let Some(addr) = req.extensions().get::>() { + return addr.0.ip().to_string(); + } + + // Fallback — should never happen behind axum::serve + "unknown".to_string() +} + +/// Build a rate-limit response with the standard `Retry-After` header. +fn too_many_requests(retry_after: u64) -> Response { + let body = serde_json::json!({ + "error": "Too many requests", + "retry_after_secs": retry_after, + }); + let mut resp = (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response(); + if let Ok(val) = HeaderValue::from_str(&retry_after.to_string()) { + resp.headers_mut().insert("retry-after", val); + } + resp +} + +/// Axum middleware: rate-limit login attempts. +/// +/// Inject via: +/// ```ignore +/// .layer(axum::middleware::from_fn_with_state(limiter, rate_limit_login)) +/// ``` +pub async fn rate_limit_login( + State(limiter): axum::extract::State>, + req: Request, + next: Next, +) -> Response { + let ip = extract_client_ip(&req); + match limiter.check_and_increment(&ip) { + Ok(_) => next.run(req).await, + Err(()) => { + tracing::warn!( + ip = %ip, + "Rate limit exceeded on login endpoint" + ); + too_many_requests(limiter.retry_after()) + } + } +} + +/// Axum middleware: rate-limit registration attempts. +pub async fn rate_limit_register( + State(limiter): axum::extract::State>, + req: Request, + next: Next, +) -> Response { + let ip = extract_client_ip(&req); + match limiter.check_and_increment(&ip) { + Ok(_) => next.run(req).await, + Err(()) => { + tracing::warn!( + ip = %ip, + "Rate limit exceeded on register endpoint" + ); + too_many_requests(limiter.retry_after()) + } + } +} + +/// Axum middleware: rate-limit token refresh attempts. +pub async fn rate_limit_refresh( + State(limiter): axum::extract::State>, + req: Request, + next: Next, +) -> Response { + let ip = extract_client_ip(&req); + match limiter.check_and_increment(&ip) { + Ok(_) => next.run(req).await, + Err(()) => { + tracing::warn!( + ip = %ip, + "Rate limit exceeded on refresh endpoint" + ); + too_many_requests(limiter.retry_after()) + } + } +} + +use axum::extract::State; diff --git a/src/main.rs b/src/main.rs index 277ae1bf..600954ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,13 +170,15 @@ async fn main() -> Result<(), Box> { ); } if config.features.enable_auth { - use interfaces::api::handlers::auth_handler::{auth_routes, login_route, register_route, refresh_route}; - use oxicloud::interfaces::api::handlers::device_auth_handler; + use interfaces::api::handlers::auth_handler::{ + auth_routes, login_route, refresh_route, register_route, + }; use oxicloud::interfaces::api::handlers::app_password_handler; + use oxicloud::interfaces::api::handlers::device_auth_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; use oxicloud::interfaces::middleware::csrf::csrf_middleware; use oxicloud::interfaces::middleware::rate_limit::{ - RateLimiter, rate_limit_login, rate_limit_register, rate_limit_refresh, + RateLimiter, rate_limit_login, rate_limit_refresh, rate_limit_register, }; // ── Rate limiters (IP-based, in-memory via moka) ──────────────── @@ -198,28 +200,40 @@ async fn main() -> Result<(), Box> { )); tracing::info!( "Rate limiting enabled — login: {}/{} s, register: {}/{} s, refresh: {}/{} s", - rl.login_max_requests, rl.login_window_secs, - rl.register_max_requests, rl.register_window_secs, - rl.refresh_max_requests, rl.refresh_window_secs, + rl.login_max_requests, + rl.login_window_secs, + rl.register_max_requests, + rl.register_window_secs, + rl.refresh_max_requests, + rl.refresh_window_secs, ); // Auth routes split by rate-limit policy let auth_login = login_route() - .layer(axum::middleware::from_fn_with_state(login_limiter.clone(), rate_limit_login)) + .layer(axum::middleware::from_fn_with_state( + login_limiter.clone(), + rate_limit_login, + )) .with_state(app_state.clone()); let auth_register = register_route() - .layer(axum::middleware::from_fn_with_state(register_limiter.clone(), rate_limit_register)) + .layer(axum::middleware::from_fn_with_state( + register_limiter.clone(), + rate_limit_register, + )) .with_state(app_state.clone()); let auth_refresh = refresh_route() - .layer(axum::middleware::from_fn_with_state(refresh_limiter.clone(), rate_limit_refresh)) + .layer(axum::middleware::from_fn_with_state( + refresh_limiter.clone(), + rate_limit_refresh, + )) .with_state(app_state.clone()); // Remaining auth routes (status, OIDC, protected /me, /logout, etc.) let auth_router = auth_routes().with_state(app_state.clone()); // Device Authorization Grant (RFC 8628) // Public endpoints: /api/auth/device/authorize + /api/auth/device/token - let device_public = device_auth_handler::device_auth_public_routes() - .with_state(app_state.clone()); + let device_public = + device_auth_handler::device_auth_public_routes().with_state(app_state.clone()); // Protected endpoints: /api/auth/device/verify, /api/auth/device/devices let device_protected = device_auth_handler::device_auth_protected_routes() .layer(axum::middleware::from_fn(csrf_middleware)) @@ -325,8 +339,8 @@ async fn main() -> Result<(), Box> { // ── Security headers ───────────────────────────────────────────────── // Applied globally so every response (API, static, DAV) carries them. - use axum::http::header::HeaderName; use axum::http::HeaderValue; + use axum::http::header::HeaderName; app = app .layer(SetResponseHeaderLayer::overriding( @@ -347,7 +361,7 @@ async fn main() -> Result<(), Box> { frame-src *; \ frame-ancestors 'none'; \ base-uri 'self'; \ - form-action 'self'" + form-action 'self'", ), )) .layer(SetResponseHeaderLayer::overriding(