From 81987e9321eabd1514e4c5b9c553db44a3c238b1 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Sun, 1 Mar 2026 20:34:12 +0100 Subject: [PATCH] fix: URL-decode DAV paths with spaces + feat: app passwords for Basic Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: - URL-decode paths in extract_webdav_path(), extract_caldav_path(), extract_carddav_path() so folders with spaces (e.g. 'My Folder') no longer return 404 when accessed via encoded URIs (%20) - Properly encode href values in PROPFIND/PROPPATCH/LOCK XML responses - Decode Destination header in MOVE/COPY operations New feature - App Passwords (API keys for DAV clients): - POST /api/auth/app-passwords → create (shows token once) - GET /api/auth/app-passwords → list (prefix only) - DELETE /api/auth/app-passwords/:id → revoke - Auth middleware now accepts both Bearer JWT and Basic Auth - Argon2 hashed, scoped (webdav/caldav/carddav), optional expiry - Compatible with DAVx5, Thunderbird, rclone, curl Tested: 12/12 E2E tests pass (create, list, WebDAV/CalDAV/CardDAV Basic Auth, URL-decode with spaces, wrong password 401, revoke, post- revoke 401). --- Cargo.lock | 1 + Cargo.toml | 1 + db/schema.sql | 21 ++ src/application/dtos/app_password_dto.rs | 85 +++++++ src/application/dtos/mod.rs | 1 + src/application/ports/auth_ports.rs | 31 +++ .../services/app_password_service.rs | 239 ++++++++++++++++++ src/application/services/mod.rs | 1 + src/common/di.rs | 34 +++ src/domain/entities/app_password.rs | 83 ++++++ src/domain/entities/mod.rs | 1 + src/infrastructure/repositories/mod.rs | 2 +- .../pg/app_password_pg_repository.rs | 188 ++++++++++++++ src/infrastructure/repositories/pg/mod.rs | 2 + .../api/handlers/app_password_handler.rs | 84 ++++++ src/interfaces/api/handlers/caldav_handler.rs | 15 +- .../api/handlers/carddav_handler.rs | 14 +- src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/webdav_handler.rs | 83 ++++-- src/interfaces/middleware/auth.rs | 133 +++++++--- src/main.rs | 11 + 21 files changed, 963 insertions(+), 68 deletions(-) create mode 100644 src/application/dtos/app_password_dto.rs create mode 100644 src/application/services/app_password_service.rs create mode 100644 src/domain/entities/app_password.rs create mode 100644 src/infrastructure/repositories/pg/app_password_pg_repository.rs create mode 100644 src/interfaces/api/handlers/app_password_handler.rs diff --git a/Cargo.lock b/Cargo.lock index 79b650f0..5aee6bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1786,6 +1786,7 @@ dependencies = [ "mime_guess", "mockall", "moka", + "percent-encoding", "quick-xml", "rand_core 0.6.4", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 49dae00b..9a8ba3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ md5 = "0.8.0" sha2 = "0.10.9" hex = "0.4.3" http-body-util = "0.1.3" +percent-encoding = "2.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] } base64 = "0.22.1" fs2 = "0.4" diff --git a/db/schema.sql b/db/schema.sql index e7c6a584..8e74c380 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -191,6 +191,27 @@ CREATE INDEX IF NOT EXISTS idx_device_codes_user_id COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication'; +-- App Passwords (application-specific passwords for DAV clients with HTTP Basic Auth) +CREATE TABLE IF NOT EXISTS auth.app_passwords ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + label VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, + prefix VARCHAR(50) NOT NULL, + scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id + ON auth.app_passwords(user_id) WHERE active = TRUE; +CREATE INDEX IF NOT EXISTS idx_app_passwords_active + ON auth.app_passwords(user_id, active) WHERE active = TRUE; + +COMMENT ON TABLE auth.app_passwords IS 'Application-specific passwords for DAV clients using HTTP Basic Auth'; + -- ============================================================ -- 2. CALDAV SCHEMA (RFC 4791) -- ============================================================ diff --git a/src/application/dtos/app_password_dto.rs b/src/application/dtos/app_password_dto.rs new file mode 100644 index 00000000..43ed6ba2 --- /dev/null +++ b/src/application/dtos/app_password_dto.rs @@ -0,0 +1,85 @@ +//! DTOs for App Password (application-specific passwords for DAV clients). + +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Request DTOs +// ============================================================================ + +/// POST /api/auth/app-passwords — create a new app password +#[derive(Debug, Deserialize)] +pub struct CreateAppPasswordRequestDto { + /// Human-readable label (e.g. "DAVx5 on Pixel 8") + pub label: String, + /// Comma-separated scopes (defaults to all DAV protocols) + #[serde(default = "default_scopes")] + pub scopes: String, + /// Optional expiration in days (None = never expires) + pub expires_in_days: Option, +} + +fn default_scopes() -> String { + "webdav,caldav,carddav".to_string() +} + +// ============================================================================ +// Response DTOs +// ============================================================================ + +/// Response when an app password is created — includes the plain-text password +/// that is shown ONCE to the user. +#[derive(Debug, Serialize)] +pub struct AppPasswordCreatedResponseDto { + /// Unique identifier for this app password. + pub id: String, + /// The label chosen by the user. + pub label: String, + /// The plain-text app password — shown only ONCE. + /// Format: `oxicloud-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` + pub password: String, + /// The username to use with HTTP Basic Auth. + pub username: String, + /// Active scopes. + pub scopes: String, + /// Expiration date or null for never. + pub expires_at: Option, + /// Usage instructions for common clients. + pub instructions: AppPasswordInstructions, +} + +/// Usage instructions included in the creation response. +#[derive(Debug, Serialize)] +pub struct AppPasswordInstructions { + pub davx5: String, + pub thunderbird: String, + pub rclone: String, + pub curl_example: String, +} + +/// Summary of an app password (list view — never includes the plain-text password). +#[derive(Debug, Serialize)] +pub struct AppPasswordSummaryDto { + pub id: String, + pub label: String, + /// First 8 chars of the token for identification. + pub prefix: String, + pub scopes: String, + pub created_at: String, + pub last_used_at: Option, + pub expires_at: Option, + pub active: bool, +} + +/// Response for list endpoint. +#[derive(Debug, Serialize)] +pub struct AppPasswordListResponseDto { + pub app_passwords: Vec, + pub total: usize, +} + +/// Response for revoke endpoint. +#[derive(Debug, Serialize)] +pub struct AppPasswordRevokeResponseDto { + pub status: String, + pub id: String, +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 76931960..9b1f8879 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,4 +1,5 @@ pub mod address_book_dto; +pub mod app_password_dto; pub mod calendar_dto; pub mod contact_dto; pub mod device_auth_dto; diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 19d722eb..90b71f39 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -1,4 +1,5 @@ use crate::common::errors::DomainError; +use crate::domain::entities::app_password::AppPassword; use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::session::Session; use crate::domain::entities::user::User; @@ -231,3 +232,33 @@ pub trait DeviceCodeStoragePort: Send + Sync + 'static { /// Delete a specific device code by ID (revocation) async fn delete_by_id(&self, id: &str) -> Result<(), DomainError>; } + +// ============================================================================ +// App Password Storage Port +// ============================================================================ + +/// Storage port for application-specific passwords (HTTP Basic Auth for DAV clients). +#[async_trait] +pub trait AppPasswordStoragePort: Send + Sync + 'static { + /// Persist a new app password (hash already computed). + async fn create(&self, app_password: AppPassword) -> Result; + + /// Get all active (non-expired) app passwords for a user. + async fn list_by_user(&self, user_id: &str) -> Result, DomainError>; + + /// Get a specific app password by ID. + async fn get_by_id(&self, id: &str) -> Result; + + /// Get all active app passwords for a user ID (for Basic auth verification). + /// This includes the password hash for verification. + async fn get_active_by_user_id(&self, user_id: &str) -> Result, DomainError>; + + /// Update the `last_used_at` timestamp after a successful authentication. + async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>; + + /// Deactivate (soft-delete) an app password. + async fn revoke(&self, id: &str) -> Result<(), DomainError>; + + /// Hard-delete expired/revoked app passwords (cleanup). + async fn delete_expired(&self) -> Result; +} diff --git a/src/application/services/app_password_service.rs b/src/application/services/app_password_service.rs new file mode 100644 index 00000000..cd542330 --- /dev/null +++ b/src/application/services/app_password_service.rs @@ -0,0 +1,239 @@ +//! 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 std::sync::Arc; + +/// 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-"; + +pub struct AppPasswordService { + repo: Arc, + hasher: Arc, + user_repo: Arc, + base_url: String, +} + +impl AppPasswordService { + pub fn new( + repo: Arc, + hasher: Arc, + user_repo: Arc, + base_url: String, + ) -> Self { + Self { + repo, + hasher, + user_repo, + base_url, + } + } + + /// 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. + 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?; + 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. + pub async fn verify_basic_auth( + &self, + username: &str, + password: &str, + ) -> Result<(String, String, String, String), DomainError> { + // 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 + 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; + + return Ok(( + user.id().to_string(), + user.username().to_string(), + user.email().to_string(), + user.role().to_string(), + )); + } + } + + Err(DomainError::unauthorized( + "Invalid username or app password", + )) + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index d8547878..bdaeed58 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -1,4 +1,5 @@ pub mod admin_settings_service; +pub mod app_password_service; pub mod auth_application_service; pub mod batch_operations; pub mod calendar_service; diff --git a/src/common/di.rs b/src/common/di.rs index 82a7e5b1..680c9b15 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -541,6 +541,7 @@ impl AppServiceFactory { wopi_lock_service: None, wopi_discovery_service: None, device_auth_service: None, + app_password_service: None, }; // 9b. Wire admin settings service when auth is available @@ -617,6 +618,37 @@ impl AppServiceFactory { app_state.device_auth_service = Some(device_auth_svc); tracing::info!("Device Authorization Grant (RFC 8628) service initialized"); } + + // 9d. Wire App Password service + { + 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 hasher: Arc = + Arc::new( + crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new( + self.config.auth.hash_memory_cost, + self.config.auth.hash_time_cost, + self.config.auth.hash_parallelism, + ), + ); + let user_repo: Arc = + Arc::new(crate::infrastructure::repositories::UserPgRepository::new( + pool.clone(), + )); + let base_url = self.config.base_url(); + + let app_pw_svc = Arc::new(AppPasswordService::new( + app_pw_repo, + hasher, + user_repo, + base_url, + )); + app_state.app_password_service = Some(app_pw_svc); + tracing::info!("App Password service initialized"); + } } // 10. Wire CalDAV/CardDAV services @@ -812,6 +844,8 @@ pub struct AppState { Option>, pub device_auth_service: Option>, + pub app_password_service: + Option>, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/domain/entities/app_password.rs b/src/domain/entities/app_password.rs new file mode 100644 index 00000000..f283cdce --- /dev/null +++ b/src/domain/entities/app_password.rs @@ -0,0 +1,83 @@ +//! App Password entity. +//! +//! Represents an application-specific password that clients (like DAVx⁵, Thunderbird) +//! can use with HTTP Basic Auth to access WebDAV/CalDAV/CardDAV endpoints without +//! requiring interactive OAuth flows. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// An application password created by a user for a specific client. +#[derive(Debug, Clone)] +pub struct AppPassword { + /// Unique identifier. + pub id: String, + /// Owner user ID. + pub user_id: String, + /// Human-readable label chosen by the user (e.g. "DAVx5 on Pixel 8"). + pub label: String, + /// Argon2 hash of the generated password token. + /// + /// The plain text token is only returned once at creation time. + pub password_hash: String, + /// First 8 characters of the plain text token, stored for display purposes + /// so the user can identify which token is which. + pub prefix: String, + /// Comma-separated scopes (e.g. "webdav,caldav,carddav"). + pub scopes: String, + /// When this app password was created. + pub created_at: DateTime, + /// When this app password was last used for authentication. + pub last_used_at: Option>, + /// Optional expiry — `None` means never expires. + pub expires_at: Option>, + /// Whether this app password is active. + pub active: bool, +} + +impl AppPassword { + /// Create a new app password entity. + /// + /// The caller is responsible for hashing the raw token and passing + /// the hash and prefix. + pub fn new( + user_id: String, + label: String, + password_hash: String, + prefix: String, + scopes: String, + expires_at: Option>, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + user_id, + label, + password_hash, + prefix, + scopes, + created_at: Utc::now(), + last_used_at: None, + expires_at, + active: true, + } + } + + /// Check whether this app password has expired. + pub fn is_expired(&self) -> bool { + if let Some(exp) = self.expires_at { + Utc::now() >= exp + } else { + false + } + } + + /// Check whether this app password is usable (active and not expired). + pub fn is_usable(&self) -> bool { + self.active && !self.is_expired() + } + + /// Check whether the given scope is granted by this app password. + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.split(',').any(|s| s.trim() == scope) + } +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index 97220649..d049c8e2 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -1,3 +1,4 @@ +pub mod app_password; pub mod calendar; pub mod calendar_event; pub mod contact; diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 3ea4faaa..42ecad96 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -3,6 +3,6 @@ pub mod pg; // Re-exportar para facilitar acceso pub use pg::{ - DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository, + 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 new file mode 100644 index 00000000..ec7b1bc6 --- /dev/null +++ b/src/infrastructure/repositories/pg/app_password_pg_repository.rs @@ -0,0 +1,188 @@ +//! 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/mod.rs b/src/infrastructure/repositories/pg/mod.rs index a697b329..88e0a4b7 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -1,4 +1,5 @@ mod address_book_pg_repository; +mod app_password_pg_repository; mod calendar_event_pg_repository; mod calendar_pg_repository; mod contact_group_pg_repository; @@ -20,6 +21,7 @@ pub mod folder_db_repository; pub mod trash_db_repository; pub use address_book_pg_repository::AddressBookPgRepository; +pub use app_password_pg_repository::AppPasswordPgRepository; pub use calendar_event_pg_repository::CalendarEventPgRepository; pub use calendar_pg_repository::CalendarPgRepository; pub use contact_group_pg_repository::ContactGroupPgRepository; diff --git a/src/interfaces/api/handlers/app_password_handler.rs b/src/interfaces/api/handlers/app_password_handler.rs new file mode 100644 index 00000000..94bb34a5 --- /dev/null +++ b/src/interfaces/api/handlers/app_password_handler.rs @@ -0,0 +1,84 @@ +//! HTTP handlers for App Password management. +//! +//! All endpoints require JWT authentication (the user must be logged in to +//! create/list/revoke their app passwords). + +use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto; +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; +use axum::extract::State; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use std::sync::Arc; + +/// Protected routes — require JWT auth middleware. +pub fn app_password_routes() -> Router> { + Router::new() + .route("/app-passwords", post(create_app_password)) + .route("/app-passwords", get(list_app_passwords)) + .route("/app-passwords/{id}", delete(revoke_app_password)) +} + +/// POST /api/auth/app-passwords — Create a new app password. +/// +/// Returns the plain-text password ONCE. The user must copy it immediately. +async fn create_app_password( + State(state): State>, + axum::Extension(user): axum::Extension, + Json(request): Json, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service + .create(&user.id, request) + .await + .map_err(|e| AppError::from(e))?; + + Ok(Json(response)) +} + +/// GET /api/auth/app-passwords — List all app passwords for the current user. +/// +/// Never returns plain-text passwords (only prefix + metadata). +async fn list_app_passwords( + State(state): State>, + axum::Extension(user): axum::Extension, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service + .list(&user.id) + .await + .map_err(|e| AppError::from(e))?; + + Ok(Json(response)) +} + +/// DELETE /api/auth/app-passwords/:id — Revoke an app password. +async fn revoke_app_password( + State(state): State>, + axum::Extension(user): axum::Extension, + axum::extract::Path(id): axum::extract::Path, +) -> Result, AppError> +{ + let service = state + .app_password_service + .as_ref() + .ok_or_else(|| AppError::internal_error("App password service not configured"))?; + + let response = service + .revoke(&user.id, &id) + .await + .map_err(|e| AppError::from(e))?; + + Ok(Json(response)) +} diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index f472bc59..14653209 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -22,6 +22,7 @@ use axum::{ response::Response, }; use bytes::Buf; +use percent_encoding::percent_decode_str; use std::sync::Arc; use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType}; @@ -86,19 +87,21 @@ async fn handle_caldav_methods_inner( } } -/// Extract the CalDAV path from the full URI path. +/// Extract the CalDAV path from the full URI path, percent-decoding the result. fn extract_caldav_path(uri_path: &str) -> String { - if let Some(pos) = uri_path.find("/caldav/") { + let encoded = if let Some(pos) = uri_path.find("/caldav/") { let after = &uri_path[pos + 8..]; - after.trim_end_matches('/').to_string() + after.trim_end_matches('/') } else if uri_path.ends_with("/caldav") { - String::new() + "" } else { uri_path .trim_start_matches('/') .trim_end_matches('/') - .to_string() - } + }; + percent_decode_str(encoded) + .decode_utf8_lossy() + .into_owned() } // ─── Helper: extract user from request ─────────────────────────────── diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 7e60faf4..8f746543 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -91,19 +91,21 @@ async fn handle_carddav_methods_inner( } } -/// Extract the CardDAV path from the full URI path. +/// Extract the CardDAV path from the full URI path, percent-decoding the result. fn extract_carddav_path(uri_path: &str) -> String { - if let Some(pos) = uri_path.find("/carddav/") { + let encoded = if let Some(pos) = uri_path.find("/carddav/") { let after = &uri_path[pos + 9..]; - after.trim_end_matches('/').to_string() + after.trim_end_matches('/') } else if uri_path.ends_with("/carddav") { - String::new() + "" } else { uri_path .trim_start_matches('/') .trim_end_matches('/') - .to_string() - } + }; + percent_encoding::percent_decode_str(encoded) + .decode_utf8_lossy() + .into_owned() } // ─── Helper: extract user from request ─────────────────────────────── diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 45fe37dd..226a80c9 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod admin_handler; +pub mod app_password_handler; pub mod auth_handler; pub mod batch_handler; pub mod device_auth_handler; diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 57486815..6298861a 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -27,8 +27,45 @@ use crate::application::ports::inbound::FolderUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet}; use std::sync::Arc; +/// Characters that MUST NOT be percent-encoded inside a URI path segment. +/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@" +/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +/// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" +const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~') + .remove(b'!') + .remove(b'$') + .remove(b'&') + .remove(b'\'') + .remove(b'(') + .remove(b')') + .remove(b'*') + .remove(b'+') + .remove(b',') + .remove(b';') + .remove(b'=') + .remove(b':') + .remove(b'@'); + +/// Percent-encode a single URI path segment (folder/file name). +fn encode_path_segment(segment: &str) -> String { + utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string() +} + +/// Percent-encode a full slash-separated path, encoding each segment individually. +pub(crate) fn encode_uri_path(path: &str) -> String { + path.split('/') + .map(|seg| encode_path_segment(seg)) + .collect::>() + .join("/") +} + // Create a custom DAV header since it's not in the standard headers const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token"); @@ -62,22 +99,24 @@ pub fn webdav_routes() -> Router> { .route("/webdav", axum::routing::any(handle_webdav_methods_root)) } -/// Extract the resource path from the request URI, stripping the `/webdav/` prefix. +/// Extract the resource path from the request URI, stripping the `/webdav/` prefix +/// and percent-decoding the result so that folder/file names with spaces and +/// special characters match the values stored in the database. fn extract_webdav_path(uri: &axum::http::Uri) -> String { let raw = uri.path(); - if let Some(rest) = raw.strip_prefix("/webdav/") { - rest.trim_end_matches('/').to_string() + let encoded = if let Some(rest) = raw.strip_prefix("/webdav/") { + rest.trim_end_matches('/') } else if raw == "/webdav" { - String::new() + "" } else { // Fallback: split-based extraction - let parts: Vec<&str> = raw.split('/').collect(); - if parts.len() > 2 { - parts[2..].join("/") - } else { - String::new() - } - } + let trimmed = raw.strip_prefix('/').unwrap_or(raw); + trimmed.trim_end_matches('/') + }; + // Decode percent-encoded characters (e.g. %20 → space) + percent_decode_str(encoded) + .decode_utf8_lossy() + .into_owned() } async fn handle_webdav_methods_root( @@ -230,7 +269,7 @@ async fn handle_propfind( let base_href = if path.is_empty() || path == "/" { "/webdav/".to_string() } else { - format!("/webdav/{}/", path) + format!("/webdav/{}/", encode_uri_path(&path)) }; // ── 5. Determine target resource ───────────────────────────── @@ -358,7 +397,7 @@ async fn build_streaming_propfind_response( { let mut w = Writer::new(&mut chunk); for subfolder in &result.items { - let href = format!("{}{}/", base_href, subfolder.name); + let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name)); WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href) .map_err(|e| std::io::Error::other(e.to_string()))?; } @@ -389,7 +428,7 @@ async fn build_streaming_propfind_response( { let mut w = Writer::new(&mut chunk); for file in &batch { - let href = format!("{}{}", base_href, file.name); + let href = format!("{}{}", base_href, encode_path_segment(&file.name)); WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href) .map_err(|e| std::io::Error::other(e.to_string()))?; } @@ -472,7 +511,7 @@ async fn handle_proppatch( } // Generate response - let href = format!("/webdav/{}", path); + let href = format!("/webdav/{}", encode_uri_path(&path)); let mut response_body = Vec::new(); WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( |e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)), @@ -860,10 +899,11 @@ async fn handle_move( .unwrap_or("T") != "F"; - // Extract destination path from URL + // Extract destination path from URL and percent-decode it let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") { let after_prefix = &destination[webdav_prefix + 8..]; - after_prefix.trim_end_matches('/').to_string() + let trimmed = after_prefix.trim_end_matches('/'); + percent_decode_str(trimmed).decode_utf8_lossy().into_owned() } else { return Err(AppError::bad_request("Invalid destination URL")); }; @@ -1021,10 +1061,11 @@ async fn handle_copy( .unwrap_or("T") != "F"; - // Extract destination path from URL + // Extract destination path from URL and percent-decode it let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") { let after_prefix = &destination[webdav_prefix + 8..]; - after_prefix.trim_end_matches('/').to_string() + let trimmed = after_prefix.trim_end_matches('/'); + percent_decode_str(trimmed).decode_utf8_lossy().into_owned() } else { return Err(AppError::bad_request("Invalid destination URL")); }; @@ -1229,7 +1270,7 @@ async fn handle_lock( }; // Generate response - let href = format!("/webdav/{}", path); + let href = format!("/webdav/{}", encode_uri_path(&path)); let mut response_body = Vec::new(); WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), @@ -1258,7 +1299,7 @@ async fn handle_lock( }; // Generate response - let href = format!("/webdav/{}", path); + let href = format!("/webdav/{}", encode_uri_path(&path)); let mut response_body = Vec::new(); WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 7f53a510..4092550f 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -154,54 +154,119 @@ impl IntoResponse for AuthError { /// Secure authentication middleware. /// -/// Validates the JWT token against the configured authentication service. -/// Does not accept bypasses, mock tokens, or URL parameters to skip validation. +/// Supports two authentication methods: +/// 1. **Bearer JWT** — standard token in `Authorization: Bearer ` +/// 2. **Basic Auth with App Passwords** — for DAV clients (DAVx⁵, Thunderbird, rclone) +/// that send `Authorization: Basic base64(username:app_password)` +/// +/// Bearer is tried first; if no Bearer header is found, Basic is attempted. pub async fn auth_middleware( State(state): State>, headers: HeaderMap, mut request: Request, next: Next, ) -> Result { - // Extract the Bearer token from the Authorization header - let token_str = headers + let auth_header = headers .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or(AuthError::TokenNotProvided)?; + .and_then(|value| value.to_str().ok()); - // Validate that the token is not empty - let token_str = token_str.trim(); - if token_str.is_empty() { - return Err(AuthError::TokenNotProvided); - } + // ── 1. Try Bearer JWT ──────────────────────────────────────── + if let Some(header_value) = auth_header { + if let Some(token_str) = header_value.strip_prefix("Bearer ") { + let token_str = token_str.trim(); + if !token_str.is_empty() { + tracing::debug!("Processing Bearer authentication token"); - tracing::debug!("Processing authentication token"); - - // Validate the token using the authentication service - if let Some(auth_service) = state.auth_service.as_ref() { - let token_service = &auth_service.token_service; - match token_service.validate_token(token_str) { - Ok(claims) => { - tracing::debug!("Token validated successfully for user: {}", claims.username); - let current_user = CurrentUser { - id: claims.sub, - username: claims.username, - email: claims.email, - role: claims.role, - }; - request.extensions_mut().insert(current_user); - return Ok(next.run(request).await); + if let Some(auth_service) = state.auth_service.as_ref() { + let token_service = &auth_service.token_service; + match token_service.validate_token(token_str) { + Ok(claims) => { + tracing::debug!( + "Token validated successfully for user: {}", + claims.username + ); + let current_user = CurrentUser { + id: claims.sub, + username: claims.username, + email: claims.email, + role: claims.role, + }; + request.extensions_mut().insert(current_user); + return Ok(next.run(request).await); + } + Err(e) => { + tracing::warn!("Bearer token validation failed: {}", e); + return Err(AuthError::InvalidToken(format!( + "Invalid token: {}", + e + ))); + } + } + } } - Err(e) => { - tracing::warn!("Token validation failed: {}", e); - return Err(AuthError::InvalidToken(format!("Invalid token: {}", e))); + } + + // ── 2. Try Basic Auth with App Passwords ───────────────── + if let Some(basic_encoded) = header_value.strip_prefix("Basic ") { + let basic_encoded = basic_encoded.trim(); + if !basic_encoded.is_empty() { + tracing::debug!("Processing Basic authentication (app password)"); + + // Decode base64(username:password) + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(basic_encoded) + .map_err(|_| { + AuthError::InvalidToken("Invalid Basic auth encoding".to_string()) + })?; + let credentials = String::from_utf8(decoded).map_err(|_| { + AuthError::InvalidToken("Invalid Basic auth encoding".to_string()) + })?; + + let (username, password) = credentials.split_once(':').ok_or_else(|| { + AuthError::InvalidToken("Invalid Basic auth format".to_string()) + })?; + + if let Some(app_pw_service) = state.app_password_service.as_ref() { + match app_pw_service.verify_basic_auth(username, password).await { + Ok((user_id, uname, email, role)) => { + tracing::debug!( + "App password authentication successful for user: {}", + uname + ); + let current_user = CurrentUser { + id: user_id, + username: uname, + email, + role, + }; + request.extensions_mut().insert(current_user); + return Ok(next.run(request).await); + } + Err(e) => { + tracing::warn!("App password verification failed: {}", e); + return Err(AuthError::InvalidToken( + "Invalid username or app password".to_string(), + )); + } + } + } else { + tracing::warn!("Basic auth attempted but app password service not configured"); + return Err(AuthError::InvalidToken( + "App passwords are not enabled".to_string(), + )); + } } } } - // If no authentication service is available, deny access - tracing::error!("Auth middleware invoked but auth service is not configured"); - Err(AuthError::AuthServiceUnavailable) + // No valid Authorization header found + if state.auth_service.is_none() { + tracing::error!("Auth middleware invoked but auth service is not configured"); + return Err(AuthError::AuthServiceUnavailable); + } + + Err(AuthError::TokenNotProvided) } /// Middleware to verify that the authenticated user has an admin role. diff --git a/src/main.rs b/src/main.rs index 46dad9f8..c9cdc960 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,6 +164,7 @@ async fn main() -> Result<(), Box> { if config.features.enable_auth { use interfaces::api::handlers::auth_handler::auth_routes; use oxicloud::interfaces::api::handlers::device_auth_handler; + use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; let auth_router = auth_routes().with_state(app_state.clone()); @@ -180,6 +181,14 @@ async fn main() -> Result<(), Box> { )) .with_state(app_state.clone()); + // App Password management endpoints (protected — require JWT) + let app_password_protected = app_password_handler::app_password_routes() + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); + // Protected API routes — require valid JWT token let protected_api = api_routes.layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -207,6 +216,8 @@ async fn main() -> Result<(), Box> { .nest("/api/auth/device", device_public) // Device Auth Grant protected endpoints (verify + device management) .nest("/api/auth/device", device_protected) + // App Password management endpoints (create, list, revoke) + .nest("/api/auth", app_password_protected) // Public API routes (share access, i18n) — no auth required .nest("/api", public_api_routes) // All other API routes are protected by auth middleware