feat(nextcloud): add Nextcloud-compatible API layer

Implement a complete Nextcloud client compatibility layer so that
Nextcloud desktop/mobile sync clients can connect to OxiCloud.

Key additions:
- Login Flow v2 (device auth) with OIDC bridge support
- WebDAV handler compatible with Nextcloud clients (PROPFIND, GET,
  PUT, DELETE, MKCOL, MOVE, COPY, HEAD, PROPPATCH)
- OCS API endpoints (user info, capabilities, notifications stubs,
  sharees, unified search)
- Basic Auth middleware with app password verification, account
  lockout integration, and blake3-keyed auth cache
- App password management: create, list, revoke via both native
  API (JWT-authenticated profile page) and Nextcloud OCS endpoints
- Nextcloud file ID mapping (oc:fileid) with persistent DB storage
- Chunked upload support (Nextcloud v2 chunking protocol)
- Trashbin WebDAV interface
- Avatar (SVG placeholder) and preview (redirect) handlers
- User profile page with app password management UI
- URL user validation on all DAV routes (403 on mismatch)
- Database schema for app_passwords and nextcloud_object_ids tables

All services are behind a `nextcloud.enabled` config flag and
cleanly separated under src/interfaces/nextcloud/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zjean
2026-03-04 14:02:15 +01:00
parent ecd1a8148a
commit 54eedf5483
64 changed files with 6761 additions and 126 deletions
+237 -24
View File
@@ -7,13 +7,14 @@ use crate::application::dtos::app_password_dto::*;
use crate::application::ports::auth_ports::{
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
};
use crate::common::errors::DomainError;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::app_password::AppPassword;
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use chrono::{Duration, Utc};
use moka::future::Cache;
use rand_core::RngCore;
use std::sync::Arc;
use std::time::Duration as StdDuration;
@@ -22,6 +23,11 @@ const TOKEN_LENGTH: usize = 32;
/// Prefix for all app password tokens (makes them easily identifiable).
const TOKEN_PREFIX: &str = "oxicloud-";
// ── Nextcloud-format app password constants ──
const NC_APP_PASSWORD_GROUPS: usize = 5;
const NC_APP_PASSWORD_GROUP_LEN: usize = 5;
const NC_PREFIX_LEN: usize = 8;
/// TTL for cached Basic Auth verification results.
/// Balances performance (avoids repeated Argon2id + DB queries) with security
/// (limits the window during which a revoked app password remains usable).
@@ -231,13 +237,16 @@ impl AppPasswordService {
user_id: &str,
id: &str,
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
// Ownership enforced at SQL level (WHERE user_id = $2).
// The get_by_id pre-check gives a clear error message when
// the password doesn't belong to the caller.
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?;
self.repo.revoke(id, user_id).await?;
// Invalidate all cached auth entries for this user so the
// revocation is effective immediately.
@@ -262,23 +271,19 @@ impl AppPasswordService {
///
/// Returns `(user_id, username, email, role)` on success.
///
/// ## Performance
/// Handles both `oxicloud-` format and Nextcloud format (`XXXXX-XXXXX-...`)
/// passwords. Uses prefix-based DB lookup to minimize Argon2id attempts.
///
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
/// the expensive Argon2id computation **and** the three PostgreSQL
/// round-trips on every repeated DAV request from the same client.
///
/// Failed verifications are **never** cached, preserving the full
/// Argon2id cost as a brute-force deterrent.
/// keyed by `blake3(username:password)`. Failed verifications are
/// **never** cached, preserving the full Argon2id cost as a brute-force
/// deterrent.
pub async fn verify_basic_auth(
&self,
username: &str,
password: &str,
) -> Result<(String, String, String, String), DomainError> {
// ── 1. Compute cache key = blake3("username:password") ────────
// The plain-text password is never stored; only the 32-byte
// cryptographic digest is used as lookup key.
let cache_key: [u8; 32] =
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
@@ -288,30 +293,57 @@ impl AppPasswordService {
}
// ── 3. Cache miss → full verification ────────────────────────
// 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() {
if !user.is_active() {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
// Try each app password hash (Argon2id — CPU-intensive)
for ap in &app_passwords {
// Determine the password form and prefix for DB lookup.
// oxicloud- format: use raw password, prefix = first 17 chars
// NC format: normalize (strip dashes/whitespace, uppercase), prefix = first 8 chars
let (verify_password, prefix) = if password.starts_with(TOKEN_PREFIX) {
let pfx = password
.get(..TOKEN_PREFIX.len() + 8)
.unwrap_or(password)
.to_string();
(password.to_string(), pfx)
} else {
let norm = nc_normalize_password(password);
match nc_token_prefix(&norm) {
Ok(pfx) => (norm, pfx),
Err(_) => {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
}
};
// Use prefix-based lookup for efficiency (fewer Argon2id attempts)
let candidates = self
.repo
.get_active_by_user_prefix(user.id(), &prefix)
.await?;
if candidates.is_empty() {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
for ap in &candidates {
if let Ok(true) = self
.hasher
.verify_password(password, &ap.password_hash)
.verify_password(&verify_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;
let result = CachedBasicAuthResult {
@@ -321,17 +353,198 @@ impl AppPasswordService {
role: user.role().to_string(),
};
// ── 4. Cache the successful result ────────────────────
self.auth_cache.insert(cache_key, result.clone()).await;
return Ok((result.user_id, result.username, result.email, result.role));
}
}
// Failed verifications are intentionally NOT cached so that
// brute-force attackers always pay the full Argon2id cost.
Err(DomainError::unauthorized(
"Invalid username or app password",
))
}
// ========================================================================
// Nextcloud-format app password methods
// ========================================================================
/// Create a Nextcloud-format app password (`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`).
///
/// Returns `(id, plain_password)`.
pub async fn create_nc(
&self,
user_id: &str,
label: &str,
) -> Result<(String, String), DomainError> {
let password = generate_nc_app_password();
let normalized = nc_normalize_password(&password);
let prefix = nc_token_prefix(&normalized)?;
let hash = self.hasher.hash_password(&normalized).await?;
let ap = AppPassword::new(
user_id.to_string(),
label.to_string(),
hash,
prefix,
"all".to_string(),
None,
);
let saved = self.repo.create(ap).await?;
Ok((saved.id, password))
}
/// Revoke an app password by matching the raw password value.
/// Scoped to the authenticated user (fixes I3 — no global prefix search).
pub async fn revoke_by_password(
&self,
user_id: &str,
password: &str,
) -> Result<(), DomainError> {
let normalized = nc_normalize_password(password);
let prefix = match nc_token_prefix(&normalized) {
Ok(pfx) => pfx,
Err(_) => return Ok(()),
};
let candidates = self
.repo
.get_active_by_user_prefix(user_id, &prefix)
.await?;
for ap in candidates {
if let Ok(true) = self
.hasher
.verify_password(&normalized, &ap.password_hash)
.await
{
self.repo.revoke(&ap.id, user_id).await?;
// Invalidate cache for this user
let uid = user_id.to_string();
self.auth_cache
.invalidate_entries_if(move |_key, val| val.user_id == uid)
.ok();
break;
}
}
Ok(())
}
/// List app passwords for a user (simple summary for NC UI).
pub async fn list_nc(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
self.repo.list_by_user(user_id).await
}
/// Delete an app password by ID, scoped to the owning user.
pub async fn delete_by_user(&self, id: &str, user_id: &str) -> Result<(), DomainError> {
let deleted = self.repo.delete_by_user_and_id(id, user_id).await?;
if !deleted {
return Err(DomainError::new(
ErrorKind::NotFound,
"AppPassword",
"App password not found",
));
}
Ok(())
}
}
// ============================================================================
// Nextcloud app password helpers (module-private)
// ============================================================================
/// Generate a Nextcloud-format app password: `XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`
/// using rejection sampling to avoid modulo bias.
fn generate_nc_app_password() -> String {
let mut rng = rand_core::OsRng;
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let len = chars.len() as u32; // 36
let mut groups = Vec::with_capacity(NC_APP_PASSWORD_GROUPS);
for _ in 0..NC_APP_PASSWORD_GROUPS {
let mut group = String::with_capacity(NC_APP_PASSWORD_GROUP_LEN);
for _ in 0..NC_APP_PASSWORD_GROUP_LEN {
let threshold = u32::MAX - (u32::MAX % len);
let idx = loop {
let val = rng.next_u32();
if val < threshold {
break (val % len) as usize;
}
};
group.push(chars[idx] as char);
}
groups.push(group);
}
groups.join("-")
}
/// Normalize a Nextcloud-format password: strip dashes/whitespace, uppercase.
fn nc_normalize_password(password: &str) -> String {
password
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| c.to_ascii_uppercase())
.collect()
}
/// Extract the first 8 characters as the token prefix for DB lookup.
fn nc_token_prefix(normalized: &str) -> Result<String, DomainError> {
if normalized.len() < NC_PREFIX_LEN {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"AppPassword",
"App password too short",
));
}
Ok(normalized[..NC_PREFIX_LEN].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_nc_app_password_format() {
let password = generate_nc_app_password();
let groups: Vec<&str> = password.split('-').collect();
assert_eq!(groups.len(), NC_APP_PASSWORD_GROUPS);
for group in &groups {
assert_eq!(group.len(), NC_APP_PASSWORD_GROUP_LEN);
assert!(group.chars().all(|c| c.is_ascii_alphanumeric()));
}
}
#[test]
fn test_nc_normalize_password_strips_dashes_and_whitespace() {
assert_eq!(
nc_normalize_password("AB12C-DE34F-GH56I"),
"AB12CDE34FGH56I"
);
}
#[test]
fn test_nc_normalize_password_uppercases() {
assert_eq!(nc_normalize_password("abc-def"), "ABCDEF");
}
#[test]
fn test_nc_token_prefix_extracts_first_8_chars() {
assert_eq!(nc_token_prefix("ABCDEFGHIJKLMNOP").unwrap(), "ABCDEFGH");
}
#[test]
fn test_nc_token_prefix_too_short() {
assert!(nc_token_prefix("SHORT").is_err());
}
#[test]
fn test_generated_nc_password_produces_valid_prefix() {
let password = generate_nc_app_password();
let normalized = nc_normalize_password(&password);
let prefix = nc_token_prefix(&normalized);
assert!(prefix.is_ok());
assert_eq!(prefix.unwrap().len(), NC_PREFIX_LEN);
}
}