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:
@@ -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 app_password_dto;
|
||||
pub mod calendar_dto;
|
||||
pub mod contact_dto;
|
||||
pub mod device_auth_dto;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 app_password_service;
|
||||
pub mod auth_application_service;
|
||||
pub mod batch_operations;
|
||||
pub mod calendar_service;
|
||||
|
||||
Reference in New Issue
Block a user