2025-03-20 09:22:31 +01:00
|
|
|
|
use axum::{
|
2026-02-14 01:29:34 +01:00
|
|
|
|
extract::{FromRequestParts, Request, State},
|
|
|
|
|
|
http::{HeaderMap, 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;
|
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;
|
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-02-12 09:41:25 +01:00
|
|
|
|
// Structure for use in Axum extractors
|
2025-03-24 16:47:42 +01:00
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub struct AuthUser {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub username: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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)]
|
|
|
|
|
|
pub struct CurrentUserId(pub String);
|
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Implement FromRequestParts for AuthUser — allows using `auth_user: AuthUser` in handlers
|
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
|
|
|
|
|
|
.get::<CurrentUser>()
|
|
|
|
|
|
.map(|cu| AuthUser {
|
|
|
|
|
|
id: cu.id.clone(),
|
|
|
|
|
|
username: cu.username.clone(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.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
|
|
|
|
|
|
.get::<CurrentUser>()
|
|
|
|
|
|
.map(|cu| CurrentUserId(cu.id.clone()))
|
|
|
|
|
|
.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)]
|
|
|
|
|
|
pub struct OptionalUserId(pub Option<String>);
|
|
|
|
|
|
|
|
|
|
|
|
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-02-14 01:29:34 +01:00
|
|
|
|
parts
|
|
|
|
|
|
.extensions
|
|
|
|
|
|
.get::<CurrentUser>()
|
|
|
|
|
|
.map(|cu| cu.id.clone()),
|
2026-02-13 08:54:51 +01:00
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Optional auth user extractor – never fails.
|
|
|
|
|
|
/// Yields `Some(AuthUser)` when auth middleware ran, `None` otherwise.
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub struct OptionalAuthUser(pub Option<AuthUser>);
|
|
|
|
|
|
|
|
|
|
|
|
impl<S> FromRequestParts<S> for OptionalAuthUser
|
|
|
|
|
|
where
|
|
|
|
|
|
S: Send + Sync,
|
|
|
|
|
|
{
|
|
|
|
|
|
type Rejection = Infallible;
|
|
|
|
|
|
|
|
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
2026-02-14 01:29:34 +01:00
|
|
|
|
Ok(OptionalAuthUser(parts.extensions.get::<CurrentUser>().map(
|
|
|
|
|
|
|cu| AuthUser {
|
2026-02-13 08:54:51 +01:00
|
|
|
|
id: cu.id.clone(),
|
|
|
|
|
|
username: cu.username.clone(),
|
2026-02-14 01:29:34 +01:00
|
|
|
|
},
|
|
|
|
|
|
)))
|
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-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()),
|
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
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
|
mut request: Request,
|
|
|
|
|
|
next: Next,
|
|
|
|
|
|
) -> Result<Response, AuthError> {
|
2026-03-01 20:34:12 +01:00
|
|
|
|
let auth_header = 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
|
|
|
|
|
|
);
|
|
|
|
|
|
let current_user = CurrentUser {
|
|
|
|
|
|
id: claims.sub,
|
|
|
|
|
|
username: claims.username,
|
|
|
|
|
|
email: claims.email,
|
|
|
|
|
|
role: claims.role,
|
|
|
|
|
|
};
|
|
|
|
|
|
request.extensions_mut().insert(current_user);
|
|
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
let current_user = CurrentUser {
|
|
|
|
|
|
id: user_id,
|
|
|
|
|
|
username: uname,
|
|
|
|
|
|
email,
|
|
|
|
|
|
role,
|
|
|
|
|
|
};
|
|
|
|
|
|
request.extensions_mut().insert(current_user);
|
|
|
|
|
|
return Ok(next.run(request).await);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("App password verification failed: {}", e);
|
|
|
|
|
|
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) =
|
|
|
|
|
|
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
|
|
|
|
|
|
{
|
2026-03-03 01:10:50 +01:00
|
|
|
|
if !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) => {
|
2026-03-03 01:49:18 +01:00
|
|
|
|
tracing::debug!("Cookie token validated for user: {}", claims.username);
|
2026-03-03 01:10:50 +01:00
|
|
|
|
let current_user = CurrentUser {
|
|
|
|
|
|
id: claims.sub,
|
|
|
|
|
|
username: claims.username,
|
|
|
|
|
|
email: claims.email,
|
|
|
|
|
|
role: claims.role,
|
|
|
|
|
|
};
|
|
|
|
|
|
request.extensions_mut().insert(current_user);
|
|
|
|
|
|
request.extensions_mut().insert(CookieAuthenticated);
|
|
|
|
|
|
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
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Err(AuthError::TokenNotProvided)
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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-02-12 09:41:25 +01:00
|
|
|
|
/// Must be applied AFTER auth_middleware, as it depends on
|
|
|
|
|
|
/// `CurrentUser` being present in the request extensions.
|
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-02-08 13:40:23 +01:00
|
|
|
|
if let Some(current_user) = request.extensions().get::<CurrentUser>() {
|
|
|
|
|
|
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-02-14 01:29:34 +01:00
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"Admin access denied for user: {} (role: {})",
|
|
|
|
|
|
current_user.username,
|
|
|
|
|
|
current_user.role
|
|
|
|
|
|
);
|
2026-02-08 13:40:23 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
tracing::warn!("Admin check failed: no authenticated user in request");
|
2025-03-20 09:22:31 +01:00
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
// Access denied
|
|
|
|
|
|
let error = AuthError::AccessDenied("Admin role required".to_string());
|
2025-03-20 09:22:31 +01:00
|
|
|
|
error.into_response()
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|