2025-03-20 09:22:31 +01:00
|
|
|
|
use axum::{
|
2026-02-14 01:29:34 +01:00
|
|
|
|
extract::{FromRequestParts, Request, State},
|
2026-07-19 10:22:12 +00:00
|
|
|
|
http::{StatusCode, header, request::Parts},
|
2025-03-20 09:22:31 +01:00
|
|
|
|
middleware::Next,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
response::{IntoResponse, Response},
|
2025-03-20 09:22:31 +01:00
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use std::convert::Infallible;
|
|
|
|
|
|
use std::sync::Arc;
|
2026-03-07 14:59:32 +01:00
|
|
|
|
use uuid::Uuid;
|
2025-03-20 09:22:31 +01:00
|
|
|
|
|
|
|
|
|
|
use crate::common::di::AppState;
|
|
|
|
|
|
|
2026-02-02 23:56:40 +01:00
|
|
|
|
// Re-export CurrentUser from application layer for use in handlers
|
|
|
|
|
|
pub use crate::application::dtos::user_dto::CurrentUser;
|
2026-03-03 15:36:42 +00:00
|
|
|
|
use crate::application::ports::auth_ports::TokenServicePort;
|
2026-06-19 11:13:49 +00:00
|
|
|
|
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
|
2025-03-20 09:22:31 +01:00
|
|
|
|
|
2026-03-03 01:10:50 +01:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-03-09 00:08:34 +01:00
|
|
|
|
// Newtype over Arc<CurrentUser> for zero-allocation extraction.
|
|
|
|
|
|
// `Deref<Target = CurrentUser>` lets handlers access `.id`, `.username`,
|
|
|
|
|
|
// `.email`, `.role` transparently — no signature changes needed.
|
2025-03-24 16:47:42 +01:00
|
|
|
|
#[derive(Clone, Debug)]
|
2026-03-09 00:08:34 +01:00
|
|
|
|
pub struct AuthUser(pub Arc<CurrentUser>);
|
|
|
|
|
|
|
|
|
|
|
|
impl std::ops::Deref for AuthUser {
|
|
|
|
|
|
type Target = CurrentUser;
|
|
|
|
|
|
#[inline]
|
|
|
|
|
|
fn deref(&self) -> &CurrentUser {
|
|
|
|
|
|
&self.0
|
|
|
|
|
|
}
|
2025-03-24 16:47:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Reusable extractor that gets the user_id of the authenticated user.
|
|
|
|
|
|
/// Automatically extracted from the `CurrentUser` inserted by the auth middleware.
|
2026-02-07 04:02:38 +01:00
|
|
|
|
///
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Usage in handlers:
|
2026-02-08 13:40:23 +01:00
|
|
|
|
/// ```ignore
|
2026-02-07 04:02:38 +01:00
|
|
|
|
/// async fn my_handler(CurrentUserId(user_id): CurrentUserId) -> impl IntoResponse { ... }
|
|
|
|
|
|
/// ```
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
2026-03-07 14:59:32 +01:00
|
|
|
|
pub struct CurrentUserId(pub Uuid);
|
2026-02-07 04:02:38 +01:00
|
|
|
|
|
2026-03-09 00:08:34 +01:00
|
|
|
|
// 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).
|
2026-02-07 04:02:38 +01:00
|
|
|
|
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
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.get::<Arc<CurrentUser>>()
|
2026-03-09 00:08:34 +01:00
|
|
|
|
.cloned()
|
|
|
|
|
|
.map(AuthUser)
|
2026-03-04 14:02:15 +01:00
|
|
|
|
.ok_or(AuthError::UserNotFound)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Implement FromRequestParts for CurrentUserId — lightweight extractor for user_id only
|
2026-02-07 04:02:38 +01:00
|
|
|
|
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
|
2026-03-07 11:23:56 +01:00
|
|
|
|
.get::<Arc<CurrentUser>>()
|
2026-03-07 14:59:32 +01:00
|
|
|
|
.map(|cu| CurrentUserId(cu.id))
|
2026-02-07 04:02:38 +01:00
|
|
|
|
.ok_or(AuthError::UserNotFound)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 08:54:51 +01:00
|
|
|
|
/// Optional user ID extractor – never fails.
|
|
|
|
|
|
/// Yields `Some(id)` when auth middleware ran, `None` otherwise.
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
2026-03-07 14:59:32 +01:00
|
|
|
|
pub struct OptionalUserId(pub Option<Uuid>);
|
2026-02-13 08:54:51 +01:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-09 14:34:07 +01:00
|
|
|
|
parts.extensions.get::<Arc<CurrentUser>>().map(|cu| cu.id),
|
2026-02-13 08:54:51 +01:00
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Error for authentication operations
|
2025-03-20 09:22:31 +01:00
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
|
pub enum AuthError {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("Token not provided")]
|
2025-03-20 09:22:31 +01:00
|
|
|
|
TokenNotProvided,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("Invalid token: {0}")]
|
2025-03-20 09:22:31 +01:00
|
|
|
|
InvalidToken(String),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("Token expired")]
|
2025-03-20 09:22:31 +01:00
|
|
|
|
TokenExpired,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("User not found")]
|
2025-03-20 09:22:31 +01:00
|
|
|
|
UserNotFound,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-06-19 11:13:49 +00:00
|
|
|
|
#[error("Account is no longer active")]
|
|
|
|
|
|
AccountInactive,
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("Access denied: {0}")]
|
2025-03-20 09:22:31 +01:00
|
|
|
|
AccessDenied(String),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
#[error("Authentication service unavailable")]
|
2026-02-08 13:40:23 +01:00
|
|
|
|
AuthServiceUnavailable,
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl IntoResponse for AuthError {
|
|
|
|
|
|
fn into_response(self) -> Response {
|
|
|
|
|
|
let (status, error_message) = match self {
|
2026-02-14 01:29:34 +01:00
|
|
|
|
AuthError::TokenNotProvided => {
|
|
|
|
|
|
(StatusCode::UNAUTHORIZED, "Token not provided".to_string())
|
|
|
|
|
|
}
|
2025-03-20 09:22:31 +01:00
|
|
|
|
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
|
2026-02-12 09:41:25 +01:00
|
|
|
|
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
|
|
|
|
|
|
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
|
2026-06-19 11:13:49 +00:00
|
|
|
|
AuthError::AccountInactive => (
|
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
|
"Account is no longer active".to_string(),
|
|
|
|
|
|
),
|
2025-03-20 09:22:31 +01:00
|
|
|
|
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
AuthError::AuthServiceUnavailable => (
|
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
|
"Authentication service unavailable".to_string(),
|
|
|
|
|
|
),
|
2025-03-20 09:22:31 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let body = axum::Json(serde_json::json!({
|
|
|
|
|
|
"error": error_message
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
(status, body).into_response()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Secure authentication middleware.
|
2026-02-08 13:40:23 +01:00
|
|
|
|
///
|
2026-03-03 01:10:50 +01:00
|
|
|
|
/// Supports three authentication methods (tried in order):
|
2026-03-01 20:34:12 +01:00
|
|
|
|
/// 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)`
|
2026-03-03 01:10:50 +01:00
|
|
|
|
/// 3. **HttpOnly Cookie** — `oxicloud_access` cookie set by the login endpoint;
|
|
|
|
|
|
/// used by browser-based sessions so tokens are never exposed to JS.
|
2026-03-01 20:34:12 +01:00
|
|
|
|
///
|
2026-03-03 01:10:50 +01:00
|
|
|
|
/// Bearer is tried first; if no Bearer header is found, Basic is attempted,
|
|
|
|
|
|
/// then the cookie fallback.
|
2025-03-20 09:22:31 +01:00
|
|
|
|
pub async fn auth_middleware(
|
2026-02-03 17:59:04 +01:00
|
|
|
|
State(state): State<Arc<AppState>>,
|
2025-03-20 09:22:31 +01:00
|
|
|
|
mut request: Request,
|
|
|
|
|
|
next: Next,
|
|
|
|
|
|
) -> Result<Response, AuthError> {
|
2026-07-19 10:22:12 +00:00
|
|
|
|
// 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()
|
2026-02-08 13:40:23 +01:00
|
|
|
|
.get(header::AUTHORIZATION)
|
2026-03-01 20:34:12 +01:00
|
|
|
|
.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");
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-03-01 20:34:12 +01:00
|
|
|
|
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
|
|
|
|
|
|
);
|
2026-07-19 10:22:12 +00:00
|
|
|
|
// 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(),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-06-19 11:13:49 +00:00
|
|
|
|
// 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),
|
|
|
|
|
|
};
|
2026-07-18 20:33:50 +00:00
|
|
|
|
// `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.
|
2026-03-07 11:23:56 +01:00
|
|
|
|
let current_user = Arc::new(CurrentUser {
|
2026-03-07 14:59:32 +01:00
|
|
|
|
id: user_id,
|
2026-07-18 20:33:50 +00:00
|
|
|
|
username: Arc::clone(&claims.username),
|
|
|
|
|
|
email: Arc::clone(&claims.email),
|
2026-06-19 11:13:49 +00:00
|
|
|
|
role,
|
2026-08-08 19:55:18 +02:00
|
|
|
|
dpop_jkt: claims.dpop_jkt.clone(),
|
2026-03-07 11:23:56 +01:00
|
|
|
|
});
|
2026-03-01 20:34:12 +01:00
|
|
|
|
request.extensions_mut().insert(current_user);
|
2026-07-17 15:19:00 +00:00
|
|
|
|
tracing::Span::current()
|
|
|
|
|
|
.record("user_id", tracing::field::display(user_id));
|
2026-03-01 20:34:12 +01:00
|
|
|
|
return Ok(next.run(request).await);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("Bearer token validation failed: {}", e);
|
2026-03-03 01:49:18 +01:00
|
|
|
|
return Err(AuthError::InvalidToken(format!("Invalid token: {}", e)));
|
2026-03-01 20:34:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|
2026-03-01 20:34:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
|
|
);
|
2026-08-08 19:55:18 +02:00
|
|
|
|
// App-password sessions are always unbound —
|
|
|
|
|
|
// they belong to NC clients / CLI / mobile
|
|
|
|
|
|
// tools without WebCrypto. DPoP middleware
|
|
|
|
|
|
// exempts them.
|
2026-03-07 11:23:56 +01:00
|
|
|
|
let current_user = Arc::new(CurrentUser {
|
2026-03-01 20:34:12 +01:00
|
|
|
|
id: user_id,
|
|
|
|
|
|
username: uname,
|
|
|
|
|
|
email,
|
|
|
|
|
|
role,
|
2026-08-08 19:55:18 +02:00
|
|
|
|
dpop_jkt: None,
|
2026-03-07 11:23:56 +01:00
|
|
|
|
});
|
2026-03-01 20:34:12 +01:00
|
|
|
|
request.extensions_mut().insert(current_user);
|
2026-07-17 15:19:00 +00:00
|
|
|
|
tracing::Span::current()
|
|
|
|
|
|
.record("user_id", tracing::field::display(user_id));
|
2026-03-01 20:34:12 +01:00
|
|
|
|
return Ok(next.run(request).await);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("App password verification failed: {}", e);
|
2026-06-19 08:38:27 +00:00
|
|
|
|
// 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",
|
|
|
|
|
|
));
|
2026-04-13 02:12:17 +02:00
|
|
|
|
}
|
2026-03-01 20:34:12 +01:00
|
|
|
|
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(),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-02-03 17:59:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-03-03 01:10:50 +01:00
|
|
|
|
// ── 3. Try HttpOnly cookie (browser sessions) ────────────────
|
|
|
|
|
|
{
|
|
|
|
|
|
use crate::interfaces::api::cookie_auth;
|
|
|
|
|
|
|
2026-03-03 01:49:18 +01:00
|
|
|
|
if let Some(token_str) =
|
2026-07-19 10:22:12 +00:00
|
|
|
|
cookie_auth::extract_cookie_str(request.headers(), cookie_auth::ACCESS_COOKIE)
|
2026-03-04 23:55:08 +01:00
|
|
|
|
&& !token_str.is_empty()
|
2026-03-03 01:49:18 +01:00
|
|
|
|
{
|
2026-03-04 23:55:08 +01:00
|
|
|
|
tracing::debug!("Processing cookie-based authentication");
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(auth_service) = state.auth_service.as_ref() {
|
|
|
|
|
|
let token_service = &auth_service.token_service;
|
2026-07-19 10:22:12 +00:00
|
|
|
|
match token_service.validate_token(token_str) {
|
2026-03-04 23:55:08 +01:00
|
|
|
|
Ok(claims) => {
|
|
|
|
|
|
tracing::debug!("Cookie token validated for user: {}", claims.username);
|
2026-07-19 10:22:12 +00:00
|
|
|
|
// 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(),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-06-19 11:13:49 +00:00
|
|
|
|
// 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,
|
2026-07-18 20:33:50 +00:00
|
|
|
|
username: Arc::clone(&claims.username),
|
|
|
|
|
|
email: Arc::clone(&claims.email),
|
2026-06-19 11:13:49 +00:00
|
|
|
|
role,
|
2026-08-08 19:55:18 +02:00
|
|
|
|
dpop_jkt: claims.dpop_jkt.clone(),
|
2026-06-19 11:13:49 +00:00
|
|
|
|
});
|
|
|
|
|
|
request.extensions_mut().insert(current_user);
|
|
|
|
|
|
request.extensions_mut().insert(CookieAuthenticated);
|
2026-07-17 15:19:00 +00:00
|
|
|
|
tracing::Span::current()
|
|
|
|
|
|
.record("user_id", tracing::field::display(user_id));
|
2026-06-19 11:13:49 +00:00
|
|
|
|
return Ok(next.run(request).await);
|
|
|
|
|
|
}
|
|
|
|
|
|
LiveRole::Revoked => {
|
|
|
|
|
|
// Fall through to the unauthenticated 401 / login redirect.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-04 23:55:08 +01:00
|
|
|
|
}
|
|
|
|
|
|
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.
|
2026-03-03 01:10:50 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 02:12:17 +02:00
|
|
|
|
// No valid credentials found via any method.
|
2026-03-01 20:34:12 +01:00
|
|
|
|
if state.auth_service.is_none() {
|
|
|
|
|
|
tracing::error!("Auth middleware invoked but auth service is not configured");
|
|
|
|
|
|
return Err(AuthError::AuthServiceUnavailable);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:38:27 +00:00
|
|
|
|
// 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"));
|
2026-04-13 02:12:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 20:34:12 +01:00
|
|
|
|
Err(AuthError::TokenNotProvided)
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 08:38:27 +00:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Middleware to verify that the authenticated user has an admin role.
|
2026-02-08 13:40:23 +01:00
|
|
|
|
///
|
2026-06-19 11:13:49 +00:00
|
|
|
|
/// 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.
|
2026-07-17 19:09:46 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// 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).
|
2026-02-14 01:29:34 +01:00
|
|
|
|
pub async fn require_admin(request: Request, next: Next) -> Response {
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Get the CurrentUser inserted by auth_middleware
|
2026-03-07 11:23:56 +01:00
|
|
|
|
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
if current_user.role == "admin" {
|
|
|
|
|
|
tracing::debug!("Admin access granted for user: {}", current_user.username);
|
|
|
|
|
|
return next.run(request).await;
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
2026-06-19 11:13:49 +00:00
|
|
|
|
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"
|
2026-02-14 01:29:34 +01:00
|
|
|
|
);
|
2026-07-17 19:09:46 +02:00
|
|
|
|
return AuthError::AccessDenied("Admin role required".to_string()).into_response();
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-07-17 19:09:46 +02:00
|
|
|
|
tracing::info!(
|
|
|
|
|
|
target: "audit",
|
|
|
|
|
|
event = "authz.admin_denied",
|
|
|
|
|
|
reason = "unauthenticated",
|
|
|
|
|
|
"👮🏻♂️ admin-only route reached with no authenticated user"
|
|
|
|
|
|
);
|
|
|
|
|
|
AuthError::TokenNotProvided.into_response()
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|
2026-06-19 08:38:27 +00:00
|
|
|
|
|
|
|
|
|
|
#[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""#),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-19 11:13:49 +00:00
|
|
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
2026-06-19 08:38:27 +00:00
|
|
|
|
}
|