Files
Oxicloud/src/application/services/app_password_service.rs
T
Claude 9754aecfa9 perf: round 19 — auth/WOPI/vCard/PROPFIND per-request & per-row alloc cuts
Benchmark-gated (examples/bench_round19_micro.rs, benches/ROUND19.md): every
section ships a BEFORE/AFTER counting-allocator arm with a byte/-value
equivalence gate and a GATE-FAIL-rollback exit. All eight pass. No Postgres.

- M1 verify_basic_auth cache key: blake3::hash(format!("{u}:{p}")) → incremental
  Hasher (byte-identical key, 2→0 allocs on every Basic-auth DAV request)
- M2 WopiTokenService: prebuild Validation/DecodingKey/EncodingKey in new()
  instead of per-call (mirrors JwtTokenService; 16→12 allocs/validate)
- V1/V2 vCard emit (contact_to_vcard/generate_vcard): FN fallback drops the
  throwaway to_string, NOTE skips the escape copy for newline-free notes, REV
  uses new common::fmt::compact_ical_utc stack renderer (11.5× vs chrono
  strftime, 3→0 allocs); per-contact 9→4 allocs
- M4 trash_service::row_to_item_dto: move name/path/blob_hash out of the owned
  row instead of cloning (3 clones/file row gone)
- M5 search cache key: Uuid::hyphenated().encode_lower stack buffer instead of
  to_string (identical u64 key, 1→0 allocs/request)
- M6 streaming PROPFIND: reuse one href buffer across the page instead of a
  format! per child (native + NC handlers; 192→3 allocs on a 64-child page)
- M7 nextcloud extract_url_user: return Cow instead of forcing into_owned
  (zero-alloc on the common ASCII-username path)

common::fmt::compact_ical_utc added with chrono-parity unit tests (CASES +
60-year sweep). cargo fmt + clippy --all-targets clean; 526 lib unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ront9bk7YMoffVQkGG47gh
2026-07-19 22:29:48 +00:00

618 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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 smol_str::SmolStr;
use std::sync::Arc;
use std::time::Duration as StdDuration;
use uuid::Uuid;
/// 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-";
// ── 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.
///
/// DAV sync clients (Nautilus, Windows Explorer, Apple Calendar, …) poll
/// continuously, and every cache miss costs a full Argon2id verification
/// (~50–100 ms of CPU) plus two DB round-trips. A 30 s TTL re-paid that
/// cost every 30 s per client; 5 min cuts it ~10× under steady sync load.
///
/// Security envelope of this window:
/// - **Revocation is immediate**: `revoke()` calls `invalidate_entries_if`
/// on this cache for the user, so a revoked password never survives in
/// cache regardless of TTL.
/// - **Expiry / deactivation are bounded by the TTL**: `expires_at` and
/// `user.is_active()` are only re-checked on a cache *miss* (the DB
/// query filters them), so an app password that expires — or a user
/// deactivated via `set_user_active` — may keep authenticating from
/// cache for at most this long. 5 min is comparable to a typical JWT
/// access-token lifetime, so the grace window is consistent across
/// auth surfaces. Lengthen with care; shorten if a tighter bound on
/// post-deactivation access is required.
const BASIC_AUTH_CACHE_TTL_SECS: u64 = 300;
/// Maximum number of cached Basic Auth verifications.
/// Each entry is ~160 bytes (32-byte key + 4 small strings), so 10 000
/// entries ≈ 1.6 MB — negligible compared to other in-memory caches.
const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000;
/// Cached identity returned after a successful Basic Auth verification.
///
/// `Arc<str>` / inline `SmolStr` fields: moka's `get` clones the value, so
/// with owned `String`s every warm Basic-auth request (all DAV traffic)
/// paid 3 string copies just to read the cached identity. Now a hit is
/// refcount bumps + a 24-byte memcpy.
#[derive(Clone)]
struct CachedBasicAuthResult {
user_id: Uuid,
username: Arc<str>,
email: Arc<str>,
role: SmolStr,
}
pub struct AppPasswordService {
repo: Arc<AppPasswordPgRepository>,
hasher: Arc<Argon2PasswordHasher>,
user_repo: Arc<UserPgRepository>,
base_url: String,
/// In-memory cache of successful Basic Auth verifications.
///
/// **Key**: `blake3(username + ":" + password)` — the plain-text password
/// is never stored; only a cryptographic hash is kept as lookup key.
///
/// **Value**: the authenticated identity (user_id, username, email, role).
///
/// **Eviction**: TTL-based (see `BASIC_AUTH_CACHE_TTL_SECS`) +
/// capacity-based (10 000 entries). Failed verifications are *never*
/// cached, so brute-force attackers always pay the full Argon2id cost.
auth_cache: Cache<[u8; 32], CachedBasicAuthResult>,
}
impl AppPasswordService {
pub fn new(
repo: Arc<AppPasswordPgRepository>,
hasher: Arc<Argon2PasswordHasher>,
user_repo: Arc<UserPgRepository>,
base_url: String,
) -> Self {
let auth_cache = Cache::builder()
.max_capacity(BASIC_AUTH_CACHE_MAX_ENTRIES)
.time_to_live(StdDuration::from_secs(BASIC_AUTH_CACHE_TTL_SECS))
.build();
tracing::info!(
"AppPasswordService Basic Auth cache initialized: TTL={}s, max={} entries",
BASIC_AUTH_CACHE_TTL_SECS,
BASIC_AUTH_CACHE_MAX_ENTRIES,
);
Self {
repo,
hasher,
user_repo,
base_url,
auth_cache,
}
}
/// 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: Uuid,
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().unwrap_or("").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,
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.to_string(),
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: Uuid) -> 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.to_string(),
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.
///
/// Also invalidates **all** cached Basic Auth entries for the owning user
/// so that the revocation takes effect immediately (instead of waiting
/// up to `BASIC_AUTH_CACHE_TTL_SECS`).
pub async fn revoke(
&self,
user_id: Uuid,
id: Uuid,
) -> 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, user_id).await?;
// Invalidate all cached auth entries for this user so the
// revocation is effective immediately.
let uid = user_id;
self.auth_cache
.invalidate_entries_if(move |_key, val| val.user_id == uid)
.ok();
tracing::debug!(
"Revoked app password {} — auth cache entries for user {} invalidated",
id,
user_id
);
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.
///
/// 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`
/// 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<(Uuid, Arc<str>, Arc<str>, SmolStr), DomainError> {
// ── 1. Compute cache key = blake3("username:password") ────────
// Stream the parts into an incremental hasher instead of
// `blake3::hash(format!("{username}:{password}").as_bytes())` — the
// `format!` heap-allocated one throw-away `String` per request (this
// runs before the cache lookup, so even cache hits paid it), and DAV
// sync clients hammer Basic auth on every request. Byte-identical key:
// blake3 is a stream hash, so `hash(a || ":" || b)` == feeding the same
// bytes in order (benches/ROUND19.md §M1).
let cache_key: [u8; 32] = {
let mut h = blake3::Hasher::new();
h.update(username.as_bytes());
h.update(b":");
h.update(password.as_bytes());
h.finalize().into()
};
// ── 2. Single-flight cache lookup ─────────────────────────────
// Concurrent misses on the same credential coalesce into ONE
// full verification: DAV sync clients hold 4-8 parallel
// connections, so an expiring cache entry used to fan out into
// K simultaneous Argon2id runs (~100-300 ms CPU + 64 MiB RAM
// apiece) every TTL — a recurring p99 spike on every DAV
// surface (8 -> 1 verifications, benches/AUTH-HERD.md).
// `try_get_with` caches only `Ok` results, so failed
// verifications are still never cached, preserving the full
// Argon2id cost as a brute-force deterrent.
let result = self
.auth_cache
.try_get_with(
cache_key,
self.verify_basic_auth_uncached(username, password),
)
.await
.map_err(
|e: std::sync::Arc<DomainError>| match std::sync::Arc::try_unwrap(e) {
Ok(err) => err,
// Another coalesced waiter still holds the Arc — rebuild
// an equivalent error (the source chain isn't clonable).
Err(shared) => {
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
}
},
)?;
Ok((result.user_id, result.username, result.email, result.role))
}
/// The uncached Basic Auth slow path: user lookup, prefix-scoped
/// candidate fetch, Argon2id verification. Runs at most once per
/// credential per TTL — `verify_basic_auth` coalesces concurrent
/// callers onto a single in-flight instance of this future.
async fn verify_basic_auth_uncached(
&self,
username: &str,
password: &str,
) -> Result<CachedBasicAuthResult, DomainError> {
let user = self
.user_repo
.get_user_by_username(username)
.await
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
if !user.is_active() {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
// 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(&verify_password, &ap.password_hash)
.await
{
let _ = self.repo.touch_last_used(ap.id).await;
// Caching happens in `verify_basic_auth`: `try_get_with`
// stores this value under the blake3 key on return.
return Ok(CachedBasicAuthResult {
user_id: user.id(),
username: Arc::from(user.username().unwrap_or("")),
email: Arc::from(user.email()),
role: SmolStr::new_static(user.role().as_str()),
});
}
}
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: Uuid,
label: &str,
) -> Result<(Uuid, 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,
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: Uuid,
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;
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: Uuid) -> 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: Uuid, user_id: Uuid) -> 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);
}
}