feat(security): HttpOnly cookies + CSP headers + CSRF double-submit protection

- Migrate auth tokens from localStorage to HttpOnly SameSite=Lax cookies
- Add cookie_auth.rs: helpers for setting/clearing auth + CSRF cookies
- Update auth middleware: 3-method auth (Bearer → Basic → Cookie)
- Add 5 security headers: CSP, X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy, Permissions-Policy
- Implement CSRF double-submit cookie pattern (csrf.rs middleware)
- Set CSRF cookie on login/refresh/oidc-exchange, clear on logout
- CookieAuthenticated marker skips CSRF for Bearer/Basic clients
- Frontend: strip all localStorage token refs from 14 JS files
- Frontend: csrf.js utility + all 52 mutating fetch/XHR calls protected
- 121 tests passing, 0 warnings
This commit is contained in:
Dionisio
2026-03-03 01:10:50 +01:00
parent 7b2a8577a9
commit d2c08d31ba
27 changed files with 579 additions and 455 deletions
+49 -3
View File
@@ -12,6 +12,12 @@ use crate::common::di::AppState;
// Re-export CurrentUser from application layer for use in handlers
pub use crate::application::dtos::user_dto::CurrentUser;
/// 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;
// Structure for use in Axum extractors
#[derive(Clone, Debug)]
pub struct AuthUser {
@@ -154,12 +160,15 @@ impl IntoResponse for AuthError {
/// Secure authentication middleware.
///
/// Supports two authentication methods:
/// 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.
/// 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,
@@ -260,7 +269,44 @@ pub async fn auth_middleware(
}
}
// No valid Authorization header found
// ── 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) {
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) => {
tracing::debug!(
"Cookie token validated 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);
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
if state.auth_service.is_none() {
tracing::error!("Auth middleware invoked but auth service is not configured");
return Err(AuthError::AuthServiceUnavailable);