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:
Generated
+1
@@ -1786,6 +1786,7 @@ dependencies = [
|
|||||||
"mime_guess",
|
"mime_guess",
|
||||||
"mockall",
|
"mockall",
|
||||||
"moka",
|
"moka",
|
||||||
|
"percent-encoding",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"rayon",
|
"rayon",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ md5 = "0.8.0"
|
|||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
http-body-util = "0.1.3"
|
http-body-util = "0.1.3"
|
||||||
|
percent-encoding = "2.3"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
|||||||
@@ -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';
|
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)
|
-- 2. CALDAV SCHEMA (RFC 4791)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for list endpoint.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppPasswordListResponseDto {
|
||||||
|
pub app_passwords: Vec<AppPasswordSummaryDto>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for revoke endpoint.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppPasswordRevokeResponseDto {
|
||||||
|
pub status: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod address_book_dto;
|
pub mod address_book_dto;
|
||||||
|
pub mod app_password_dto;
|
||||||
pub mod calendar_dto;
|
pub mod calendar_dto;
|
||||||
pub mod contact_dto;
|
pub mod contact_dto;
|
||||||
pub mod device_auth_dto;
|
pub mod device_auth_dto;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::app_password::AppPassword;
|
||||||
use crate::domain::entities::device_code::DeviceCode;
|
use crate::domain::entities::device_code::DeviceCode;
|
||||||
use crate::domain::entities::session::Session;
|
use crate::domain::entities::session::Session;
|
||||||
use crate::domain::entities::user::User;
|
use crate::domain::entities::user::User;
|
||||||
@@ -231,3 +232,33 @@ pub trait DeviceCodeStoragePort: Send + Sync + 'static {
|
|||||||
/// Delete a specific device code by ID (revocation)
|
/// Delete a specific device code by ID (revocation)
|
||||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError>;
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<dyn AppPasswordStoragePort>,
|
||||||
|
hasher: Arc<dyn PasswordHasherPort>,
|
||||||
|
user_repo: Arc<dyn UserStoragePort>,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppPasswordService {
|
||||||
|
pub fn new(
|
||||||
|
repo: Arc<dyn AppPasswordStoragePort>,
|
||||||
|
hasher: Arc<dyn PasswordHasherPort>,
|
||||||
|
user_repo: Arc<dyn UserStoragePort>,
|
||||||
|
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<AppPasswordCreatedResponseDto, DomainError> {
|
||||||
|
// 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<AppPasswordListResponseDto, DomainError> {
|
||||||
|
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<AppPasswordRevokeResponseDto, DomainError> {
|
||||||
|
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",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod admin_settings_service;
|
pub mod admin_settings_service;
|
||||||
|
pub mod app_password_service;
|
||||||
pub mod auth_application_service;
|
pub mod auth_application_service;
|
||||||
pub mod batch_operations;
|
pub mod batch_operations;
|
||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ impl AppServiceFactory {
|
|||||||
wopi_lock_service: None,
|
wopi_lock_service: None,
|
||||||
wopi_discovery_service: None,
|
wopi_discovery_service: None,
|
||||||
device_auth_service: None,
|
device_auth_service: None,
|
||||||
|
app_password_service: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 9b. Wire admin settings service when auth is available
|
// 9b. Wire admin settings service when auth is available
|
||||||
@@ -617,6 +618,37 @@ impl AppServiceFactory {
|
|||||||
app_state.device_auth_service = Some(device_auth_svc);
|
app_state.device_auth_service = Some(device_auth_svc);
|
||||||
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
|
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<dyn crate::application::ports::auth_ports::AppPasswordStoragePort> =
|
||||||
|
Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
||||||
|
let hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> =
|
||||||
|
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<dyn crate::application::ports::auth_ports::UserStoragePort> =
|
||||||
|
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
|
// 10. Wire CalDAV/CardDAV services
|
||||||
@@ -812,6 +844,8 @@ pub struct AppState {
|
|||||||
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
|
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
|
||||||
pub device_auth_service:
|
pub device_auth_service:
|
||||||
Option<Arc<crate::application::services::device_auth_service::DeviceAuthService>>,
|
Option<Arc<crate::application::services::device_auth_service::DeviceAuthService>>,
|
||||||
|
pub app_password_service:
|
||||||
|
Option<Arc<crate::application::services::app_password_service::AppPasswordService>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// All AppState construction is done via struct literal in build_app_state().
|
// All AppState construction is done via struct literal in build_app_state().
|
||||||
|
|||||||
@@ -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<Utc>,
|
||||||
|
/// When this app password was last used for authentication.
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
/// Optional expiry — `None` means never expires.
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
/// 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<DateTime<Utc>>,
|
||||||
|
) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod app_password;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_event;
|
pub mod calendar_event;
|
||||||
pub mod contact;
|
pub mod contact;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ pub mod pg;
|
|||||||
|
|
||||||
// Re-exportar para facilitar acceso
|
// Re-exportar para facilitar acceso
|
||||||
pub use pg::{
|
pub use pg::{
|
||||||
DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository,
|
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository,
|
||||||
FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository,
|
FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<PgPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppPasswordPgRepository {
|
||||||
|
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool(&self) -> &PgPool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||||
|
async fn create(&self, ap: AppPassword) -> Result<AppPassword, DomainError> {
|
||||||
|
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<Vec<AppPassword>, 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<AppPassword, DomainError> {
|
||||||
|
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<Vec<AppPassword>, 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<u64, DomainError> {
|
||||||
|
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<Utc>,
|
||||||
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppPasswordRow> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod address_book_pg_repository;
|
mod address_book_pg_repository;
|
||||||
|
mod app_password_pg_repository;
|
||||||
mod calendar_event_pg_repository;
|
mod calendar_event_pg_repository;
|
||||||
mod calendar_pg_repository;
|
mod calendar_pg_repository;
|
||||||
mod contact_group_pg_repository;
|
mod contact_group_pg_repository;
|
||||||
@@ -20,6 +21,7 @@ pub mod folder_db_repository;
|
|||||||
pub mod trash_db_repository;
|
pub mod trash_db_repository;
|
||||||
|
|
||||||
pub use address_book_pg_repository::AddressBookPgRepository;
|
pub use address_book_pg_repository::AddressBookPgRepository;
|
||||||
|
pub use app_password_pg_repository::AppPasswordPgRepository;
|
||||||
pub use calendar_event_pg_repository::CalendarEventPgRepository;
|
pub use calendar_event_pg_repository::CalendarEventPgRepository;
|
||||||
pub use calendar_pg_repository::CalendarPgRepository;
|
pub use calendar_pg_repository::CalendarPgRepository;
|
||||||
pub use contact_group_pg_repository::ContactGroupPgRepository;
|
pub use contact_group_pg_repository::ContactGroupPgRepository;
|
||||||
|
|||||||
@@ -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<Arc<AppState>> {
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||||
|
Json(request): Json<CreateAppPasswordRequestDto>,
|
||||||
|
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, 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<Arc<AppState>>,
|
||||||
|
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||||
|
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordListResponseDto>, 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<Arc<AppState>>,
|
||||||
|
axum::Extension(user): axum::Extension<CurrentUser>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordRevokeResponseDto>, 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))
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use bytes::Buf;
|
use bytes::Buf;
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
|
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 {
|
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..];
|
let after = &uri_path[pos + 8..];
|
||||||
after.trim_end_matches('/').to_string()
|
after.trim_end_matches('/')
|
||||||
} else if uri_path.ends_with("/caldav") {
|
} else if uri_path.ends_with("/caldav") {
|
||||||
String::new()
|
""
|
||||||
} else {
|
} else {
|
||||||
uri_path
|
uri_path
|
||||||
.trim_start_matches('/')
|
.trim_start_matches('/')
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string()
|
};
|
||||||
}
|
percent_decode_str(encoded)
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper: extract user from request ───────────────────────────────
|
// ─── Helper: extract user from request ───────────────────────────────
|
||||||
|
|||||||
@@ -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 {
|
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..];
|
let after = &uri_path[pos + 9..];
|
||||||
after.trim_end_matches('/').to_string()
|
after.trim_end_matches('/')
|
||||||
} else if uri_path.ends_with("/carddav") {
|
} else if uri_path.ends_with("/carddav") {
|
||||||
String::new()
|
""
|
||||||
} else {
|
} else {
|
||||||
uri_path
|
uri_path
|
||||||
.trim_start_matches('/')
|
.trim_start_matches('/')
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string()
|
};
|
||||||
}
|
percent_encoding::percent_decode_str(encoded)
|
||||||
|
.decode_utf8_lossy()
|
||||||
|
.into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper: extract user from request ───────────────────────────────
|
// ─── Helper: extract user from request ───────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod admin_handler;
|
pub mod admin_handler;
|
||||||
|
pub mod app_password_handler;
|
||||||
pub mod auth_handler;
|
pub mod auth_handler;
|
||||||
pub mod batch_handler;
|
pub mod batch_handler;
|
||||||
pub mod device_auth_handler;
|
pub mod device_auth_handler;
|
||||||
|
|||||||
@@ -27,8 +27,45 @@ use crate::application::ports::inbound::FolderUseCase;
|
|||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::CurrentUser;
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet};
|
||||||
use std::sync::Arc;
|
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::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
// Create a custom DAV header since it's not in the standard headers
|
// Create a custom DAV header since it's not in the standard headers
|
||||||
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||||
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
|
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
|
||||||
@@ -62,22 +99,24 @@ pub fn webdav_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/webdav", axum::routing::any(handle_webdav_methods_root))
|
.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 {
|
fn extract_webdav_path(uri: &axum::http::Uri) -> String {
|
||||||
let raw = uri.path();
|
let raw = uri.path();
|
||||||
if let Some(rest) = raw.strip_prefix("/webdav/") {
|
let encoded = if let Some(rest) = raw.strip_prefix("/webdav/") {
|
||||||
rest.trim_end_matches('/').to_string()
|
rest.trim_end_matches('/')
|
||||||
} else if raw == "/webdav" {
|
} else if raw == "/webdav" {
|
||||||
String::new()
|
""
|
||||||
} else {
|
} else {
|
||||||
// Fallback: split-based extraction
|
// Fallback: split-based extraction
|
||||||
let parts: Vec<&str> = raw.split('/').collect();
|
let trimmed = raw.strip_prefix('/').unwrap_or(raw);
|
||||||
if parts.len() > 2 {
|
trimmed.trim_end_matches('/')
|
||||||
parts[2..].join("/")
|
};
|
||||||
} else {
|
// Decode percent-encoded characters (e.g. %20 → space)
|
||||||
String::new()
|
percent_decode_str(encoded)
|
||||||
}
|
.decode_utf8_lossy()
|
||||||
}
|
.into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_webdav_methods_root(
|
async fn handle_webdav_methods_root(
|
||||||
@@ -230,7 +269,7 @@ async fn handle_propfind(
|
|||||||
let base_href = if path.is_empty() || path == "/" {
|
let base_href = if path.is_empty() || path == "/" {
|
||||||
"/webdav/".to_string()
|
"/webdav/".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("/webdav/{}/", path)
|
format!("/webdav/{}/", encode_uri_path(&path))
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 5. Determine target resource ─────────────────────────────
|
// ── 5. Determine target resource ─────────────────────────────
|
||||||
@@ -358,7 +397,7 @@ async fn build_streaming_propfind_response(
|
|||||||
{
|
{
|
||||||
let mut w = Writer::new(&mut chunk);
|
let mut w = Writer::new(&mut chunk);
|
||||||
for subfolder in &result.items {
|
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)
|
WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.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);
|
let mut w = Writer::new(&mut chunk);
|
||||||
for file in &batch {
|
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)
|
WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
@@ -472,7 +511,7 @@ async fn handle_proppatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate response
|
// Generate response
|
||||||
let href = format!("/webdav/{}", path);
|
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||||
let mut response_body = Vec::new();
|
let mut response_body = Vec::new();
|
||||||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||||||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||||||
@@ -860,10 +899,11 @@ async fn handle_move(
|
|||||||
.unwrap_or("T")
|
.unwrap_or("T")
|
||||||
!= "F";
|
!= "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 destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
|
||||||
let after_prefix = &destination[webdav_prefix + 8..];
|
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 {
|
} else {
|
||||||
return Err(AppError::bad_request("Invalid destination URL"));
|
return Err(AppError::bad_request("Invalid destination URL"));
|
||||||
};
|
};
|
||||||
@@ -1021,10 +1061,11 @@ async fn handle_copy(
|
|||||||
.unwrap_or("T")
|
.unwrap_or("T")
|
||||||
!= "F";
|
!= "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 destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
|
||||||
let after_prefix = &destination[webdav_prefix + 8..];
|
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 {
|
} else {
|
||||||
return Err(AppError::bad_request("Invalid destination URL"));
|
return Err(AppError::bad_request("Invalid destination URL"));
|
||||||
};
|
};
|
||||||
@@ -1229,7 +1270,7 @@ async fn handle_lock(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate response
|
// Generate response
|
||||||
let href = format!("/webdav/{}", path);
|
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||||
let mut response_body = Vec::new();
|
let mut response_body = Vec::new();
|
||||||
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
||||||
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
||||||
@@ -1258,7 +1299,7 @@ async fn handle_lock(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate response
|
// Generate response
|
||||||
let href = format!("/webdav/{}", path);
|
let href = format!("/webdav/{}", encode_uri_path(&path));
|
||||||
let mut response_body = Vec::new();
|
let mut response_body = Vec::new();
|
||||||
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|
||||||
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
|
||||||
|
|||||||
@@ -154,54 +154,119 @@ impl IntoResponse for AuthError {
|
|||||||
|
|
||||||
/// Secure authentication middleware.
|
/// Secure authentication middleware.
|
||||||
///
|
///
|
||||||
/// Validates the JWT token against the configured authentication service.
|
/// Supports two authentication methods:
|
||||||
/// Does not accept bypasses, mock tokens, or URL parameters to skip validation.
|
/// 1. **Bearer JWT** — standard token in `Authorization: Bearer <token>`
|
||||||
|
/// 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(
|
pub async fn auth_middleware(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AuthError> {
|
) -> Result<Response, AuthError> {
|
||||||
// Extract the Bearer token from the Authorization header
|
let auth_header = headers
|
||||||
let token_str = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok());
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
|
||||||
.ok_or(AuthError::TokenNotProvided)?;
|
|
||||||
|
|
||||||
// Validate that the token is not empty
|
// ── 1. Try Bearer JWT ────────────────────────────────────────
|
||||||
let token_str = token_str.trim();
|
if let Some(header_value) = auth_header {
|
||||||
if token_str.is_empty() {
|
if let Some(token_str) = header_value.strip_prefix("Bearer ") {
|
||||||
return Err(AuthError::TokenNotProvided);
|
let token_str = token_str.trim();
|
||||||
}
|
if !token_str.is_empty() {
|
||||||
|
tracing::debug!("Processing Bearer authentication token");
|
||||||
|
|
||||||
tracing::debug!("Processing authentication token");
|
if let Some(auth_service) = state.auth_service.as_ref() {
|
||||||
|
let token_service = &auth_service.token_service;
|
||||||
// Validate the token using the authentication service
|
match token_service.validate_token(token_str) {
|
||||||
if let Some(auth_service) = state.auth_service.as_ref() {
|
Ok(claims) => {
|
||||||
let token_service = &auth_service.token_service;
|
tracing::debug!(
|
||||||
match token_service.validate_token(token_str) {
|
"Token validated successfully for user: {}",
|
||||||
Ok(claims) => {
|
claims.username
|
||||||
tracing::debug!("Token validated successfully for user: {}", claims.username);
|
);
|
||||||
let current_user = CurrentUser {
|
let current_user = CurrentUser {
|
||||||
id: claims.sub,
|
id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
email: claims.email,
|
email: claims.email,
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
};
|
};
|
||||||
request.extensions_mut().insert(current_user);
|
request.extensions_mut().insert(current_user);
|
||||||
return Ok(next.run(request).await);
|
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
|
// No valid Authorization header found
|
||||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
if state.auth_service.is_none() {
|
||||||
Err(AuthError::AuthServiceUnavailable)
|
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.
|
/// Middleware to verify that the authenticated user has an admin role.
|
||||||
|
|||||||
+11
@@ -164,6 +164,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if config.features.enable_auth {
|
if config.features.enable_auth {
|
||||||
use interfaces::api::handlers::auth_handler::auth_routes;
|
use interfaces::api::handlers::auth_handler::auth_routes;
|
||||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||||
|
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||||
|
|
||||||
let auth_router = auth_routes().with_state(app_state.clone());
|
let auth_router = auth_routes().with_state(app_state.clone());
|
||||||
@@ -180,6 +181,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
))
|
))
|
||||||
.with_state(app_state.clone());
|
.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
|
// Protected API routes — require valid JWT token
|
||||||
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
|
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
|
||||||
app_state.clone(),
|
app_state.clone(),
|
||||||
@@ -207,6 +216,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.nest("/api/auth/device", device_public)
|
.nest("/api/auth/device", device_public)
|
||||||
// Device Auth Grant protected endpoints (verify + device management)
|
// Device Auth Grant protected endpoints (verify + device management)
|
||||||
.nest("/api/auth/device", device_protected)
|
.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
|
// Public API routes (share access, i18n) — no auth required
|
||||||
.nest("/api", public_api_routes)
|
.nest("/api", public_api_routes)
|
||||||
// All other API routes are protected by auth middleware
|
// All other API routes are protected by auth middleware
|
||||||
|
|||||||
Reference in New Issue
Block a user