fix: URL-decode DAV paths with spaces + feat: app passwords for Basic Auth

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).
This commit is contained in:
Dionisio
2026-03-01 20:34:12 +01:00
parent 48d853360e
commit 81987e9321
21 changed files with 963 additions and 68 deletions
+31
View File
@@ -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<AppPassword, DomainError>;
/// Get all active (non-expired) app passwords for a user.
async fn list_by_user(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError>;
/// Get a specific app password by ID.
async fn get_by_id(&self, id: &str) -> Result<AppPassword, DomainError>;
/// 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<Vec<AppPassword>, 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<u64, DomainError>;
}