Files
Oxicloud/src/interfaces/middleware/auth.rs
T

356 lines
14 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::{HeaderMap, 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;
/// 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("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::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>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth_header = 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
);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username,
email: claims.email,
role: claims.role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
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", user_id.to_string());
return Ok(next.run(request).await);
}
Err(e) => {
tracing::warn!("App password verification failed: {}", e);
// For WebDAV: include WWW-Authenticate so the client
// knows to re-prompt rather than silently failing.
if request.uri().path().starts_with("/webdav") {
return Ok(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(
"Invalid username or app password",
))
.unwrap());
}
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_value(&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);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username,
email: claims.email,
role: claims.role,
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
return Ok(next.run(request).await);
}
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 WebDAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Nautilus, Cyberduck,
// Windows Explorer, macOS Finder) know to prompt for a username/password.
// Non-WebDAV routes return the standard AuthError which renders without
// this header — keeping browser sessions redirecting to /login as before.
if request.uri().path().starts_with("/webdav") {
return Ok(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("Authentication required"))
.unwrap());
}
Err(AuthError::TokenNotProvided)
}
/// 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.
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::warn!(
"Admin access denied for user: {} (role: {})",
current_user.username,
current_user.role
);
} else {
tracing::warn!("Admin check failed: no authenticated user in request");
}
// Access denied
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
}