Files
Oxicloud/src/interfaces/middleware/auth.rs
T
Claude c930f865b0 perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn
Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).

Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
  caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
  columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
  instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
  result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
  the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
  axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
  chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
  fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.

Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
  a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
  on reset) vs a brand-new set over the whole accumulated list each page:
  22.3x over a 40-page drain (O(N^2)→O(N)).

Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).

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

506 lines
21 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.
use axum::{
extract::{FromRequestParts, Request, State},
http::{StatusCode, header, request::Parts},
middleware::Next,
response::{IntoResponse, Response},
};
use std::convert::Infallible;
use std::sync::Arc;
use uuid::Uuid;
use crate::common::di::AppState;
// Re-export CurrentUser from application layer for use in handlers
pub use crate::application::dtos::user_dto::CurrentUser;
use crate::application::ports::auth_ports::TokenServicePort;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Marker inserted into request extensions when the user was authenticated
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
/// The CSRF middleware uses this to decide whether CSRF validation is required.
#[derive(Clone, Copy, Debug)]
pub struct CookieAuthenticated;
// Newtype over Arc<CurrentUser> for zero-allocation extraction.
// `Deref<Target = CurrentUser>` lets handlers access `.id`, `.username`,
// `.email`, `.role` transparently — no signature changes needed.
#[derive(Clone, Debug)]
pub struct AuthUser(pub Arc<CurrentUser>);
impl std::ops::Deref for AuthUser {
type Target = CurrentUser;
#[inline]
fn deref(&self) -> &CurrentUser {
&self.0
}
}
/// Reusable extractor that gets the user_id of the authenticated user.
/// Automatically extracted from the `CurrentUser` inserted by the auth middleware.
///
/// Usage in handlers:
/// ```ignore
/// async fn my_handler(CurrentUserId(user_id): CurrentUserId) -> impl IntoResponse { ... }
/// ```
#[derive(Clone, Debug)]
pub struct CurrentUserId(pub Uuid);
// Implement FromRequestParts for AuthUser — allows using `auth_user: AuthUser` in handlers.
// Cost: 1 atomic increment (~1 ns) instead of 3 String clones (~100 ns + 3 mallocs).
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Arc<CurrentUser>>()
.cloned()
.map(AuthUser)
.ok_or(AuthError::UserNotFound)
}
}
// Implement FromRequestParts for CurrentUserId — lightweight extractor for user_id only
impl<S> FromRequestParts<S> for CurrentUserId
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Arc<CurrentUser>>()
.map(|cu| CurrentUserId(cu.id))
.ok_or(AuthError::UserNotFound)
}
}
/// Optional user ID extractor – never fails.
/// Yields `Some(id)` when auth middleware ran, `None` otherwise.
#[derive(Clone, Debug)]
pub struct OptionalUserId(pub Option<Uuid>);
impl<S> FromRequestParts<S> for OptionalUserId
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(OptionalUserId(
parts.extensions.get::<Arc<CurrentUser>>().map(|cu| cu.id),
))
}
}
// Error for authentication operations
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Token not provided")]
TokenNotProvided,
#[error("Invalid token: {0}")]
InvalidToken(String),
#[error("Token expired")]
TokenExpired,
#[error("User not found")]
UserNotFound,
#[error("Account is no longer active")]
AccountInactive,
#[error("Access denied: {0}")]
AccessDenied(String),
#[error("Authentication service unavailable")]
AuthServiceUnavailable,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AuthError::TokenNotProvided => {
(StatusCode::UNAUTHORIZED, "Token not provided".to_string())
}
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
AuthError::AccountInactive => (
StatusCode::UNAUTHORIZED,
"Account is no longer active".to_string(),
),
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
AuthError::AuthServiceUnavailable => (
StatusCode::INTERNAL_SERVER_ERROR,
"Authentication service unavailable".to_string(),
),
};
let body = axum::Json(serde_json::json!({
"error": error_message
}));
(status, body).into_response()
}
}
/// Secure authentication middleware.
///
/// Supports three authentication methods (tried in order):
/// 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)`
/// 3. **HttpOnly Cookie** — `oxicloud_access` cookie set by the login endpoint;
/// used by browser-based sessions so tokens are never exposed to JS.
///
/// Bearer is tried first; if no Bearer header is found, Basic is attempted,
/// then the cookie fallback.
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
// Borrow the Authorization header straight from the request instead of
// taking axum's `HeaderMap` extractor, which clones the whole map (~2
// allocs) on every authenticated request purely to read it
// (benches/ROUND14.md §A4). The borrow is dead by the time each arm
// reaches `request.extensions_mut()` / `next.run(request)` (NLL), so no
// owned copy is needed.
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
// ── 1. Try Bearer JWT ────────────────────────────────────────
if let Some(header_value) = auth_header {
if let Some(token_str) = header_value.strip_prefix("Bearer ") {
let token_str = token_str.trim();
if !token_str.is_empty() {
tracing::debug!("Processing Bearer authentication token");
if let Some(auth_service) = state.auth_service.as_ref() {
let token_service = &auth_service.token_service;
match token_service.validate_token(token_str) {
Ok(claims) => {
tracing::debug!(
"Token validated successfully for user: {}",
claims.username
);
// Pre-parsed at decode time (benches/ROUND14.md §A3);
// nil only for a malformed sub, which we reject as before.
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// A cryptographically valid token must not outlive the
// account: re-check the live record so deactivation,
// deletion and demotion take effect within the flags-cache
// TTL instead of waiting for token expiry. The returned
// role is authoritative — never the frozen JWT claim.
let role = match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => role,
LiveRole::Revoked => return Err(AuthError::AccountInactive),
};
// `username`/`email` are `Arc<str>` refcount
// bumps out of the cached claims; `role` is an
// inline SmolStr — the whole build is 1 alloc
// (the `Arc::new`) instead of 4.
let current_user = Arc::new(CurrentUser {
id: user_id,
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
tracing::warn!("Bearer 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 = Arc::new(CurrentUser {
id: user_id,
username: uname,
email,
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
tracing::warn!("App password verification failed: {}", e);
// For DAV clients: include WWW-Authenticate so the client
// re-prompts for credentials rather than failing silently.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge(
"Invalid username or app password",
));
}
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(),
));
}
}
}
}
// ── 3. Try HttpOnly cookie (browser sessions) ────────────────
{
use crate::interfaces::api::cookie_auth;
if let Some(token_str) =
cookie_auth::extract_cookie_str(request.headers(), cookie_auth::ACCESS_COOKIE)
&& !token_str.is_empty()
{
tracing::debug!("Processing cookie-based authentication");
if let Some(auth_service) = state.auth_service.as_ref() {
let token_service = &auth_service.token_service;
match token_service.validate_token(token_str) {
Ok(claims) => {
tracing::debug!("Cookie token validated for user: {}", claims.username);
// Pre-parsed at decode time (benches/ROUND14.md §A3).
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
// /login, exactly like an invalid or expired cookie.
match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => {
let current_user = Arc::new(CurrentUser {
id: user_id,
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
// Fall through to the unauthenticated 401 / login redirect.
}
}
}
Err(e) => {
tracing::debug!("Cookie token validation failed: {}", e);
// Don't return error — fall through to "no token" so
// the browser gets a 401 and can redirect to /login.
}
}
}
}
}
// No valid credentials found via any method.
if state.auth_service.is_none() {
tracing::error!("Auth middleware invoked but auth service is not configured");
return Err(AuthError::AuthServiceUnavailable);
}
// For DAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Thunderbird, DAVx5,
// Apple Calendar/Contacts, Nautilus, Cyberduck, Windows Explorer, macOS
// Finder) know to prompt for credentials and retry. Unlike `curl -u`, these
// clients do NOT send Basic credentials preemptively — without the
// challenge they never authenticate and fail with "discovery failed" / 401.
// Non-DAV routes return the standard AuthError which renders without this
// header — keeping browser sessions redirecting to /login as before.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge("Authentication required"));
}
Err(AuthError::TokenNotProvided)
}
/// DAV protocol surfaces (WebDAV, CalDAV, CardDAV) authenticate over HTTP Basic.
/// Spec-compliant clients (Thunderbird, DAVx5, Apple Calendar/Contacts, file
/// managers) only send credentials after receiving a `401` carrying a
/// `WWW-Authenticate: Basic` challenge, so these paths must emit it. Browser and
/// JSON-API routes deliberately do not, so they keep redirecting to `/login`.
fn is_dav_path(path: &str) -> bool {
path.starts_with("/webdav") || path.starts_with("/caldav") || path.starts_with("/carddav")
}
/// Build the `401 Unauthorized` Basic-auth challenge shared by every DAV
/// surface, so clients re-prompt for credentials instead of failing silently.
fn dav_basic_auth_challenge(message: &'static str) -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(message))
.unwrap()
}
/// Middleware to verify that the authenticated user has an admin role.
///
/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser`
/// being present in the request extensions. The role carried by
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
/// here within the flags-cache TTL.
///
/// Denial shapes distinguish authn from authz:
/// - `CurrentUser` present, role != "admin" → 403 Forbidden.
/// - `CurrentUser` absent → 401 Unauthorized. Should not happen in
/// practice (auth_middleware guards against it), but the
/// defensive fallback returns the honest shape: "we don't know
/// who you are" is 401, not "we know you and refuse" (403).
pub async fn require_admin(request: Request, next: Next) -> Response {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
if current_user.role == "admin" {
tracing::debug!("Admin access granted for user: {}", current_user.username);
return next.run(request).await;
}
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %current_user.id,
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
return AuthError::AccessDenied("Admin role required".to_string()).into_response();
}
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
AuthError::TokenNotProvided.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dav_paths_receive_basic_auth_challenge() {
// Regression for #480: CalDAV/CardDAV clients (Thunderbird, DAVx5) only
// send credentials after a 401 carrying WWW-Authenticate. All three DAV
// surfaces must qualify so the challenge is emitted.
for path in [
"/webdav/",
"/webdav/admin/file.txt",
"/caldav/",
"/caldav/admin/cal/",
"/carddav/",
"/carddav/principals/admin/",
] {
assert!(is_dav_path(path), "{path} should be treated as a DAV path");
}
}
#[test]
fn non_dav_paths_do_not_receive_basic_auth_challenge() {
for path in [
"/",
"/api/files",
"/login",
"/index.html",
"/.well-known/caldav",
] {
assert!(
!is_dav_path(path),
"{path} must not get a Basic-auth challenge (browser/API surface)"
);
}
}
#[test]
fn challenge_sets_www_authenticate_header() {
let resp = dav_basic_auth_challenge("Authentication required");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok()),
Some(r#"Basic realm="OxiCloud""#),
);
}
#[test]
fn account_inactive_maps_to_401() {
// A token that is still cryptographically valid but whose account was
// deactivated/deleted must be rejected with 401 (credentials no longer
// valid), so browsers redirect to /login rather than seeing a 403.
let resp = AuthError::AccountInactive.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}