Files
Oxicloud/src/application/ports/auth_ports.rs
T
Dionisio 81987e9321 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).
2026-03-01 20:34:12 +01:00

265 lines
9.8 KiB
Rust

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;
use async_trait::async_trait;
// ============================================================================
// Cryptography Ports - Extracted from Domain to maintain Clean Architecture
// ============================================================================
/// Port for password hashing operations.
///
/// This trait abstracts cryptographic password operations, allowing the domain
/// layer to remain independent of specific hashing implementations (argon2, bcrypt, etc.)
///
/// Methods are async because implementations (e.g. Argon2) are CPU-intensive
/// and must run on a blocking thread pool to avoid starving Tokio workers.
#[async_trait]
pub trait PasswordHasherPort: Send + Sync + 'static {
/// Hash a plain text password
async fn hash_password(&self, password: &str) -> Result<String, DomainError>;
/// Verify a plain text password against a hash
async fn verify_password(&self, password: &str, hash: &str) -> Result<bool, DomainError>;
}
/// Claims contained in a JWT token
#[derive(Debug, Clone)]
pub struct TokenClaims {
/// Subject identifier (user ID)
pub sub: String,
/// Expiration timestamp (seconds since Unix epoch)
pub exp: i64,
/// Issued at timestamp (seconds since Unix epoch)
pub iat: i64,
/// JWT unique ID
pub jti: String,
/// Username
pub username: String,
/// User email
pub email: String,
/// User role
pub role: String,
}
/// Port for JWT token operations.
///
/// This trait abstracts token generation and validation, allowing the domain
/// layer to remain independent of specific JWT implementations.
pub trait TokenServicePort: Send + Sync + 'static {
/// Generate an access token for a user
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
/// Validate a token and extract its claims
fn validate_token(&self, token: &str) -> Result<TokenClaims, DomainError>;
/// Generate a refresh token
fn generate_refresh_token(&self) -> String;
/// Get refresh token expiry in seconds
fn refresh_token_expiry_secs(&self) -> i64;
/// Get refresh token expiry in days
fn refresh_token_expiry_days(&self) -> i64;
}
// ============================================================================
// Storage Ports
// ============================================================================
#[async_trait]
pub trait UserStoragePort: Send + Sync + 'static {
/// Creates a new user
async fn create_user(&self, user: User) -> Result<User, DomainError>;
/// Gets a user by ID
async fn get_user_by_id(&self, id: &str) -> Result<User, DomainError>;
/// Gets a user by username
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
/// Gets a user by email
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError>;
/// Updates an existing user
async fn update_user(&self, user: User) -> Result<User, DomainError>;
/// Updates only the storage usage of a user
async fn update_storage_usage(
&self,
user_id: &str,
usage_bytes: i64,
) -> Result<(), DomainError>;
/// Lists users with pagination
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
/// Lists users by role (e.g., "admin" or "user")
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
/// Deletes a user by their ID
async fn delete_user(&self, user_id: &str) -> Result<(), DomainError>;
/// Changes a user's password
async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError>;
/// Finds a user by OIDC provider + subject pair
async fn get_user_by_oidc_subject(
&self,
provider: &str,
subject: &str,
) -> Result<User, DomainError>;
/// Activates or deactivates a user
async fn set_user_active_status(&self, user_id: &str, active: bool) -> Result<(), DomainError>;
/// Changes a user's role
async fn change_role(&self, user_id: &str, role: &str) -> Result<(), DomainError>;
/// Updates a user's storage quota
async fn update_storage_quota(
&self,
user_id: &str,
quota_bytes: i64,
) -> Result<(), DomainError>;
/// Counts the total number of users
async fn count_users(&self) -> Result<i64, DomainError>;
}
// ============================================================================
// OIDC Port
// ============================================================================
/// Represents the token set returned by the OIDC provider after code exchange
#[derive(Debug, Clone)]
pub struct OidcTokenSet {
pub access_token: String,
pub id_token: String,
pub refresh_token: Option<String>,
}
/// Claims extracted from the validated OIDC ID token
#[derive(Debug, Clone)]
pub struct OidcIdClaims {
pub sub: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub preferred_username: Option<String>,
pub name: Option<String>,
pub groups: Vec<String>,
}
/// Port for OIDC operations — implemented in infrastructure layer
#[async_trait]
pub trait OidcServicePort: Send + Sync + 'static {
/// Get the authorization URL for redirecting the user to the IdP.
/// Includes PKCE code_challenge (S256) and nonce for ID token binding.
/// This is async because it may need to fetch the OIDC discovery document.
async fn get_authorize_url(
&self,
state: &str,
nonce: &str,
pkce_challenge: &str,
) -> Result<String, DomainError>;
/// Exchange an authorization code for tokens, providing PKCE code_verifier.
async fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
) -> Result<OidcTokenSet, DomainError>;
/// Validate an ID token and extract claims.
/// If `expected_nonce` is provided, verifies the `nonce` claim matches.
async fn validate_id_token(
&self,
id_token: &str,
expected_nonce: Option<&str>,
) -> Result<OidcIdClaims, DomainError>;
/// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims)
async fn fetch_user_info(&self, access_token: &str) -> Result<OidcIdClaims, DomainError>;
/// Get the OIDC provider display name
fn provider_name(&self) -> &str;
}
#[async_trait]
pub trait SessionStoragePort: Send + Sync + 'static {
/// Creates a new session
async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
/// Gets a session by refresh token
async fn get_session_by_refresh_token(
&self,
refresh_token: &str,
) -> Result<Session, DomainError>;
/// Revokes a specific session
async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>;
/// Revokes all sessions of a user
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>;
}
// ============================================================================
// Device Authorization Grant Port (RFC 8628)
// ============================================================================
#[async_trait]
pub trait DeviceCodeStoragePort: Send + Sync + 'static {
/// Persist a new device code flow
async fn create_device_code(&self, device_code: DeviceCode) -> Result<DeviceCode, DomainError>;
/// Find a device code by its opaque device_code token (used by client polling)
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError>;
/// Find a pending device code by the short user_code (used on verification page)
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError>;
/// Update a device code (status change, token storage, poll timestamp, etc.)
async fn update_device_code(&self, device_code: DeviceCode) -> Result<(), DomainError>;
/// Delete expired device codes (cleanup job)
async fn delete_expired(&self) -> Result<u64, DomainError>;
/// List authorized device codes for a user (for UI management)
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError>;
/// 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>;
}