From d2c08d31baa1a65a0c056553f29dcb5bb202c586 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 3 Mar 2026 01:10:50 +0100 Subject: [PATCH] feat(security): HttpOnly cookies + CSP headers + CSRF double-submit protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/interfaces/api/cookie_auth.rs | 148 +++++++++++ src/interfaces/api/handlers/auth_handler.rs | 138 +++++----- src/interfaces/api/mod.rs | 1 + src/interfaces/middleware/auth.rs | 52 +++- src/interfaces/middleware/csrf.rs | 75 ++++++ src/interfaces/middleware/mod.rs | 1 + src/main.rs | 68 ++++- static/admin.html | 1 + static/device-verify.html | 34 +-- static/index.html | 1 + static/js/app/authSession.js | 104 ++------ static/js/app/filesView.js | 5 +- static/js/app/userMenu.js | 18 +- static/js/core/csrf.js | 28 ++ static/js/features/auth/auth.js | 269 +++++--------------- static/js/features/files/contextMenus.js | 12 +- static/js/features/files/fileOperations.js | 19 +- static/js/features/files/inlineViewer.js | 23 +- static/js/features/files/wopiEditor.js | 5 +- static/js/features/library/favorites.js | 5 +- static/js/features/library/recent.js | 5 +- static/js/features/sharing/fileSharing.js | 6 +- static/js/views/admin/admin.js | 4 +- static/js/views/profile/profile.js | 4 +- static/js/views/shared/sharedView.js | 6 +- static/login.html | 1 + static/profile.html | 1 + 27 files changed, 579 insertions(+), 455 deletions(-) create mode 100644 src/interfaces/api/cookie_auth.rs create mode 100644 src/interfaces/middleware/csrf.rs create mode 100644 static/js/core/csrf.js diff --git a/src/interfaces/api/cookie_auth.rs b/src/interfaces/api/cookie_auth.rs new file mode 100644 index 00000000..4fd3b7cc --- /dev/null +++ b/src/interfaces/api/cookie_auth.rs @@ -0,0 +1,148 @@ +//! HttpOnly cookie helpers for secure token transport. +//! +//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that +//! browser-based JavaScript cannot read them (mitigates XSS token theft). +//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var +//! (default: auto-detect from `OXICLOUD_BASE_URL`). +//! +//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set +//! alongside the auth cookies. The frontend must read it and echo its +//! value back as `X-CSRF-Token` on every state-changing request. +//! A middleware (`csrf_middleware`) validates the match. +//! +//! DAV clients continue to use `Authorization: Basic` with app passwords +//! and are completely unaffected by this mechanism. + +use axum::http::header::SET_COOKIE; +use axum::http::{HeaderMap, HeaderValue}; + +/// Cookie name for the JWT access token. +pub const ACCESS_COOKIE: &str = "oxicloud_access"; +/// Cookie name for the opaque refresh token. +pub const REFRESH_COOKIE: &str = "oxicloud_refresh"; +/// Cookie name for the CSRF double-submit token (readable by JS). +pub const CSRF_COOKIE: &str = "oxicloud_csrf"; +/// Header the frontend must send with the CSRF token value. +pub const CSRF_HEADER: &str = "x-csrf-token"; + +/// Whether the `Secure` flag should be set on cookies. +/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`) +/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`. +fn cookie_secure() -> bool { + if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") { + return v == "true" || v == "1"; + } + // Auto-detect from base URL + std::env::var("OXICLOUD_BASE_URL") + .map(|u| u.starts_with("https")) + .unwrap_or(false) +} + +/// Build a `Set-Cookie` header value. +fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String { + let secure = if cookie_secure() { "; Secure" } else { "" }; + format!( + "{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}", + ) +} + +/// Append `Set-Cookie` headers for both access and refresh tokens. +/// +/// The access cookie covers all paths (`/`) because the API lives under +/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc. +/// +/// The refresh cookie is restricted to `/api/auth` so it is only sent +/// when the client explicitly calls the refresh or logout endpoints. +pub fn append_auth_cookies( + headers: &mut HeaderMap, + access_token: &str, + refresh_token: &str, + access_expiry_secs: i64, + refresh_expiry_secs: i64, +) { + if let Ok(val) = HeaderValue::from_str(&build_cookie( + ACCESS_COOKIE, + access_token, + "/", + access_expiry_secs, + )) { + headers.append(SET_COOKIE, val); + } + if let Ok(val) = HeaderValue::from_str(&build_cookie( + REFRESH_COOKIE, + refresh_token, + "/api/auth", + refresh_expiry_secs, + )) { + headers.append(SET_COOKIE, val); + } +} + +/// Append `Set-Cookie` headers that immediately expire both auth cookies, +/// effectively logging the user out on the browser side. +pub fn append_clear_cookies(headers: &mut HeaderMap) { + for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] { + let secure = if cookie_secure() { "; Secure" } else { "" }; + let val = format!( + "{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}", + ); + if let Ok(hv) = HeaderValue::from_str(&val) { + headers.append(SET_COOKIE, hv); + } + } +} + +/// Extract a named cookie value from the `Cookie` request header. +pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option { + let cookie_header = headers.get(axum::http::header::COOKIE)?; + let cookie_str = cookie_header.to_str().ok()?; + + for pair in cookie_str.split(';') { + let pair = pair.trim(); + if let Some(val) = pair.strip_prefix(name) { + let val = val.strip_prefix('=')?; + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + +// ──────────────────────────────────────────────────────────── +// CSRF double-submit cookie helpers +// ──────────────────────────────────────────────────────────── + +/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like). +pub fn generate_csrf_token() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it +/// via `document.cookie` and echo it back in the `X-CSRF-Token` header. +fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String { + let secure = if cookie_secure() { "; Secure" } else { "" }; + format!( + "{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}", + ) +} + +/// Append a CSRF double-submit cookie alongside the auth cookies. +/// Should be called in every endpoint that also sets auth cookies. +pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) { + let token = generate_csrf_token(); + if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) { + headers.append(SET_COOKIE, val); + } +} + +/// Clear the CSRF cookie (on logout). +pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) { + let secure = if cookie_secure() { "; Secure" } else { "" }; + let val = format!( + "{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}", + ); + if let Ok(hv) = HeaderValue::from_str(&val) { + headers.append(SET_COOKIE, hv); + } +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 70c6ca2c..bc0279d0 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -2,7 +2,7 @@ use axum::{ Router, extract::{Json, Query, State}, http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Redirect}, + response::{IntoResponse, Redirect, Response}, routing::{get, post, put}, }; use std::sync::Arc; @@ -12,7 +12,9 @@ use crate::application::dtos::user_dto::{ RefreshTokenDto, RegisterDto, }; use crate::common::di::AppState; +use crate::interfaces::api::cookie_auth; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUserId; pub fn auth_routes() -> Router> { // Routes that do NOT require authentication @@ -103,7 +105,7 @@ async fn register( async fn login( State(state): State>, Json(dto): Json, -) -> Result { +) -> Result { // Add detailed logging for debugging tracing::info!("Login attempt for user: {}", dto.username); @@ -153,7 +155,20 @@ async fn login( )); } - Ok((StatusCode::OK, Json(auth_response))) + // ── Set HttpOnly cookies so the browser never stores tokens in JS ── + let mut response = (StatusCode::OK, Json(&auth_response)).into_response(); + cookie_auth::append_auth_cookies( + response.headers_mut(), + &auth_response.access_token, + &auth_response.refresh_token, + auth_response.expires_in, + state.core.config.auth.refresh_token_expiry_secs, + ); + cookie_auth::append_csrf_cookie( + response.headers_mut(), + auth_response.expires_in, + ); + Ok(response) } Err(err) => { tracing::error!("Login failed for user {}: {}", dto.username, err); @@ -162,58 +177,63 @@ async fn login( } } +/// Token refresh — accepts the refresh token from **either**: +/// 1. JSON body `{ "refresh_token": "..." }` (API clients, backward compat) +/// 2. HttpOnly cookie `oxicloud_refresh` (browsers) async fn refresh_token( State(state): State>, - Json(dto): Json, -) -> Result { - // Add rate limiting for token refresh to prevent refresh loops - // Check if this refresh token is being used too frequently - - // Log the refresh attempt for debugging + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { tracing::info!("Token refresh requested"); - // Normal process for real tokens let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; + // Try JSON body first (backward compat), then fall back to HttpOnly cookie + let refresh_tok = serde_json::from_slice::(&body) + .ok() + .map(|dto| dto.refresh_token) + .or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE)) + .ok_or_else(|| AppError::unauthorized("Refresh token required (JSON body or cookie)"))?; + + let dto = RefreshTokenDto { + refresh_token: refresh_tok, + }; + let auth_response = auth_service .auth_application_service .refresh_token(dto) .await?; - // Log successful token refresh tracing::info!("Token refresh successful, new token issued"); - Ok((StatusCode::OK, Json(auth_response))) + let mut response = (StatusCode::OK, Json(&auth_response)).into_response(); + cookie_auth::append_auth_cookies( + response.headers_mut(), + &auth_response.access_token, + &auth_response.refresh_token, + auth_response.expires_in, + state.core.config.auth.refresh_token_expiry_secs, + ); + cookie_auth::append_csrf_cookie( + response.headers_mut(), + auth_response.expires_in, + ); + Ok(response) } async fn get_current_user( State(state): State>, - headers: HeaderMap, + CurrentUserId(user_id): CurrentUserId, ) -> Result { - // Normal process for all users let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - // Extract and validate the token directly - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - // Validate the token and get claims - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let user_id = claims.sub; - // First, update the storage usage statistics // IMPORTANT: We await the calculation to return updated data if let Some(storage_usage_service) = state.storage_usage_service.as_ref() { @@ -247,7 +267,7 @@ async fn get_current_user( async fn change_password( State(state): State>, - headers: HeaderMap, + CurrentUserId(user_id): CurrentUserId, Json(dto): Json, ) -> Result { let auth_service = state @@ -255,22 +275,9 @@ async fn change_password( .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - // Extract and validate the token directly - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - // Validate the token and get claims - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - auth_service .auth_application_service - .change_password(&claims.sub, dto) + .change_password(&user_id, dto) .await?; Ok(StatusCode::OK) @@ -279,32 +286,32 @@ async fn change_password( async fn logout( State(state): State>, headers: HeaderMap, -) -> Result { + CurrentUserId(user_id): CurrentUserId, +) -> Result { let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - // Extract and validate the token directly + // Obtain the raw access token from Bearer header OR cookie let token = headers .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(String::from) + .or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)) .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - // Validate the token and get claims - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - // Use access token for logout (we don't have refresh token in headers) auth_service .auth_application_service - .logout(&claims.sub, token) + .logout(&user_id, &token) .await?; - Ok(StatusCode::OK) + // Clear HttpOnly + CSRF cookies so the browser forgets the session + let mut response = StatusCode::OK.into_response(); + cookie_auth::append_clear_cookies(response.headers_mut()); + cookie_auth::append_clear_csrf_cookie(response.headers_mut()); + Ok(response) } /// Get system status - returns whether admin is configured @@ -452,7 +459,7 @@ async fn oidc_callback( async fn oidc_exchange( State(state): State>, Json(body): Json, -) -> Result { +) -> Result { let auth_service = state .auth_service .as_ref() @@ -471,5 +478,18 @@ async fn oidc_exchange( auth_response.user.username ); - Ok((StatusCode::OK, Json(auth_response))) + // Set HttpOnly cookies for the browser + let mut response = (StatusCode::OK, Json(&auth_response)).into_response(); + cookie_auth::append_auth_cookies( + response.headers_mut(), + &auth_response.access_token, + &auth_response.refresh_token, + auth_response.expires_in, + state.core.config.auth.refresh_token_expiry_secs, + ); + cookie_auth::append_csrf_cookie( + response.headers_mut(), + auth_response.expires_in, + ); + Ok(response) } diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 9908acce..f026147d 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -1,3 +1,4 @@ +pub mod cookie_auth; pub mod handlers; pub mod routes; diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 4092550f..e3b664d3 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -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 ` /// 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>, 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); diff --git a/src/interfaces/middleware/csrf.rs b/src/interfaces/middleware/csrf.rs new file mode 100644 index 00000000..6540fe90 --- /dev/null +++ b/src/interfaces/middleware/csrf.rs @@ -0,0 +1,75 @@ +//! CSRF double-submit cookie middleware. +//! +//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were +//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must** +//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf` +//! cookie. Requests authenticated via `Bearer` or `Basic` headers are +//! exempt because they are not vulnerable to CSRF — the browser never +//! attaches those automatically. +//! +//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through. + +use axum::{ + extract::Request, + http::{Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::interfaces::api::cookie_auth; +use crate::interfaces::middleware::auth::CookieAuthenticated; + +/// Methods considered safe (no side-effects) — CSRF check is skipped. +const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS]; + +/// Middleware that enforces CSRF protection for cookie-authenticated browser +/// sessions using the **double-submit cookie** pattern. +/// +/// Must be applied **after** `auth_middleware` so that the +/// `CookieAuthenticated` marker is available in extensions. +pub async fn csrf_middleware(request: Request, next: Next) -> Result { + // Safe methods never need CSRF validation. + if SAFE_METHODS.contains(request.method()) { + return Ok(next.run(request).await); + } + + // Only enforce for cookie-authenticated sessions. + let is_cookie_auth = request.extensions().get::().is_some(); + if !is_cookie_auth { + return Ok(next.run(request).await); + } + + // Extract the CSRF token from the cookie. + let cookie_token = cookie_auth::extract_cookie_value( + request.headers(), + cookie_auth::CSRF_COOKIE, + ); + + // Extract the CSRF token from the request header. + let header_token = request + .headers() + .get(cookie_auth::CSRF_HEADER) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match (cookie_token, header_token) { + (Some(c), Some(h)) if !c.is_empty() && c == h => { + // Tokens match — allow the request through. + Ok(next.run(request).await) + } + _ => { + tracing::warn!( + method = %request.method(), + uri = %request.uri(), + "CSRF validation failed: missing or mismatched token" + ); + Err(( + StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "error": "CSRF token missing or invalid" + })), + ) + .into_response()) + } + } +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 0e4a05d5..39099589 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod csrf; diff --git a/src/main.rs b/src/main.rs index 8a983517..fee7407b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type}; use axum::Router; use axum::extract::DefaultBodyLimit; use tower_http::limit::RequestBodyLimitLayer; +use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -173,6 +174,7 @@ async fn main() -> Result<(), Box> { use oxicloud::interfaces::api::handlers::device_auth_handler; use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; + use oxicloud::interfaces::middleware::csrf::csrf_middleware; let auth_router = auth_routes().with_state(app_state.clone()); @@ -182,6 +184,7 @@ async fn main() -> Result<(), Box> { .with_state(app_state.clone()); // Protected endpoints: /api/auth/device/verify, /api/auth/device/devices let device_protected = device_auth_handler::device_auth_protected_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), auth_middleware, @@ -190,6 +193,7 @@ async fn main() -> Result<(), Box> { // App Password management endpoints (protected — require JWT) let app_password_protected = app_password_handler::app_password_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), auth_middleware, @@ -197,10 +201,12 @@ async fn main() -> Result<(), Box> { .with_state(app_state.clone()); // Protected API routes — require valid JWT token - let protected_api = api_routes.layer(axum::middleware::from_fn_with_state( - app_state.clone(), - auth_middleware, - )); + let protected_api = api_routes + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )); // CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested) let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state( @@ -240,10 +246,12 @@ async fn main() -> Result<(), Box> { // Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware) if let Some((wopi_protocol, wopi_api)) = wopi_routes { - let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state( - app_state.clone(), - auth_middleware, - )); + let wopi_api_protected = wopi_api + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )); app = app .nest("/wopi", wopi_protocol) .nest("/api/wopi", wopi_api_protected); @@ -273,6 +281,50 @@ async fn main() -> Result<(), Box> { // Without this Axum caps Multipart bodies at 2 MB. app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)); + // ── Security headers ───────────────────────────────────────────────── + // Applied globally so every response (API, static, DAV) carries them. + use axum::http::header::HeaderName; + use axum::http::HeaderValue; + + app = app + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("content-security-policy"), + // NOTE: script-src includes 'unsafe-inline' because several HTML + // pages still use inline event handlers (onclick, onsubmit) and + // + diff --git a/static/device-verify.html b/static/device-verify.html index 95062da9..ccd03345 100644 --- a/static/device-verify.html +++ b/static/device-verify.html @@ -137,6 +137,7 @@
+ diff --git a/static/index.html b/static/index.html index 6d063959..1ddbe404 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,7 @@ + diff --git a/static/js/app/authSession.js b/static/js/app/authSession.js index ca969c1e..ae64bf38 100644 --- a/static/js/app/authSession.js +++ b/static/js/app/authSession.js @@ -3,25 +3,13 @@ */ async function refreshUserData() { - const TOKEN_KEY = 'oxicloud_token'; const USER_DATA_KEY = 'oxicloud_user'; - const token = localStorage.getItem(TOKEN_KEY); - console.log('refreshUserData called, token:', token ? token.substring(0, 20) + '...' : 'null'); - - if (!token) { - console.log('No valid token, skipping user data refresh'); - return null; - } - try { - console.log('Fetching /api/auth/me...'); + console.log('Fetching /api/auth/me (cookie-based)...'); const response = await fetch('/api/auth/me', { method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } + credentials: 'same-origin' }); console.log('/api/auth/me response status:', response.status); @@ -47,9 +35,6 @@ async function refreshUserData() { async function checkAuthentication() { try { - const TOKEN_KEY = 'oxicloud_token'; - const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token'; - const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry'; const USER_DATA_KEY = 'oxicloud_user'; const urlParams = new URLSearchParams(window.location.search); @@ -60,8 +45,9 @@ async function checkAuthentication() { try { const exchangeResponse = await fetch('/api/auth/oidc/exchange', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: oidcCode }) + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify({ code: oidcCode }), + credentials: 'same-origin' }); if (!exchangeResponse.ok) { @@ -74,43 +60,14 @@ async function checkAuthentication() { const data = await exchangeResponse.json(); console.log('OIDC token exchange successful'); - const token = data.access_token || data.token; - const refreshToken = data.refresh_token || data.refreshToken; - - if (token) { - localStorage.setItem(TOKEN_KEY, token); - if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - - let parsedExpiry = false; - const tokenParts = token.split('.'); - if (tokenParts.length === 3) { - try { - const payload = JSON.parse(atob(tokenParts[1])); - if (payload.exp) { - const expiryDate = new Date(payload.exp * 1000); - if (!isNaN(expiryDate.getTime())) { - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString()); - parsedExpiry = true; - } - } - } catch (e) { - console.error('Error parsing JWT:', e); - } - } - if (!parsedExpiry) { - const expiry = new Date(); - expiry.setDate(expiry.getDate() + 30); - localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toISOString()); - } - - if (data.user) { - localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); - } - - window.history.replaceState({}, document.title, '/'); - window.location.reload(); - return; + // Tokens are now in HttpOnly cookies set by the server. + if (data.user) { + localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); } + + window.history.replaceState({}, document.title, '/'); + window.location.reload(); + return; } catch (err) { console.error('OIDC exchange error:', err); window.location.href = '/login?source=oidc_error'; @@ -118,18 +75,12 @@ async function checkAuthentication() { } } - const token = localStorage.getItem(TOKEN_KEY); - - if (!token) { - console.log('No token found, redirecting to login'); - window.location.href = '/login?source=app'; - return; - } - - console.log('Token found, proceeding with app initialization'); + // Check session validity by calling /api/auth/me (cookie auto-sent) + console.log('Checking session via /api/auth/me...'); const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); if (userData.username) { + // We have cached user data — render immediately, refresh in background const userInitials = userData.username.substring(0, 2).toUpperCase(); document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => { el.textContent = userInitials; @@ -144,6 +95,15 @@ async function checkAuthentication() { refreshUserData().then(freshData => { if (freshData) { console.log('Storage usage updated from server'); + } else { + // Session expired — try silent refresh first + console.warn('Session may have expired, trying refresh...'); + fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: '{}' }) + .then(r => r.ok ? refreshUserData() : Promise.reject(new Error('refresh failed'))) + .catch(() => { + localStorage.removeItem(USER_DATA_KEY); + window.location.href = '/login?source=session_expired'; + }); } }).catch(err => { console.warn('Could not refresh user data:', err); @@ -151,7 +111,8 @@ async function checkAuthentication() { resolveHomeFolder().then(() => window.loadFiles()); } else { - console.log('No user data, attempting to fetch from server'); + // No cached user data — must verify session from server + console.log('No cached user data, fetching from server'); try { const freshData = await refreshUserData(); if (freshData && freshData.username) { @@ -161,26 +122,17 @@ async function checkAuthentication() { resolveHomeFolder().then(() => window.loadFiles()); } else { console.warn('Could not retrieve user data, redirecting to login'); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); window.location.href = '/login?source=invalid_session'; } } catch (err) { console.error('Failed to fetch user data:', err); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); window.location.href = '/login?source=session_error'; } } } catch (error) { console.error('Error during authentication check:', error); - localStorage.removeItem('oxicloud_token'); - localStorage.removeItem('oxicloud_refresh_token'); - localStorage.removeItem('oxicloud_token_expiry'); localStorage.removeItem('oxicloud_user'); window.location.href = '/login?source=auth_error'; } @@ -191,9 +143,7 @@ async function resolveHomeFolder() { if (app.userHomeFolderId) return; try { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - const response = await fetch('/api/folders', { headers }); + const response = await fetch('/api/folders', { credentials: 'same-origin' }); if (!response.ok) { console.warn(`Could not fetch home folder: ${response.status}`); return; diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 2e45dc07..6b5b73c2 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -48,16 +48,13 @@ async function loadFiles(options = {}) { console.log(`Loading subfolder content: ${app.currentPath}`); } - const token = localStorage.getItem('oxicloud_token'); const headers = { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } const requestOptions = { headers, + credentials: 'same-origin', cache: 'no-store' }; diff --git a/static/js/app/userMenu.js b/static/js/app/userMenu.js index aa5ca916..c3522b96 100644 --- a/static/js/app/userMenu.js +++ b/static/js/app/userMenu.js @@ -224,18 +224,16 @@ function showUserProfileModal() { } function logout() { - const TOKEN_KEY = 'oxicloud_token'; - const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token'; - const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry'; const USER_DATA_KEY = 'oxicloud_user'; - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); - localStorage.removeItem(USER_DATA_KEY); - - sessionStorage.removeItem('redirect_count'); - window.location.href = '/login'; + // Tell the server to clear HttpOnly cookies + fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() }) + .catch(() => {}) // Best-effort + .finally(() => { + localStorage.removeItem(USER_DATA_KEY); + sessionStorage.removeItem('redirect_count'); + window.location.href = '/login'; + }); } window.setupUserMenu = setupUserMenu; diff --git a/static/js/core/csrf.js b/static/js/core/csrf.js new file mode 100644 index 00000000..1cae763c --- /dev/null +++ b/static/js/core/csrf.js @@ -0,0 +1,28 @@ +/** + * CSRF double-submit cookie utility. + * + * Reads the `oxicloud_csrf` cookie (which is NOT HttpOnly) and provides + * its value as the `X-CSRF-Token` header on mutating requests. + * + * Usage: + * // In any fetch call that changes state: + * fetch(url, { method: 'POST', headers: { ...getCsrfHeaders(), 'Content-Type': 'application/json' } }) + * + * The server-side `csrf_middleware` validates that the header value matches + * the cookie for every POST/PUT/DELETE/PATCH request authenticated via + * HttpOnly cookies. + */ + +// eslint-disable-next-line no-unused-vars +function getCsrfToken() { + const match = document.cookie + .split('; ') + .find(row => row.startsWith('oxicloud_csrf=')); + return match ? match.split('=')[1] : ''; +} + +// eslint-disable-next-line no-unused-vars +function getCsrfHeaders() { + const token = getCsrfToken(); + return token ? { 'X-CSRF-Token': token } : {}; +} diff --git a/static/js/features/auth/auth.js b/static/js/features/auth/auth.js index 6b8e418f..c6982468 100644 --- a/static/js/features/auth/auth.js +++ b/static/js/features/auth/auth.js @@ -10,10 +10,8 @@ const REGISTER_ENDPOINT = `${API_URL}/register`; const ME_ENDPOINT = `${API_URL}/me`; const REFRESH_ENDPOINT = `${API_URL}/refresh`; -// Storage keys -const TOKEN_KEY = 'oxicloud_token'; -const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token'; -const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry'; +// Storage keys — tokens are now in HttpOnly cookies (set by server). +// Only non-sensitive display data is kept in localStorage. const USER_DATA_KEY = 'oxicloud_user'; const LOCALE_KEY = 'oxicloud-locale'; const FIRST_RUN_KEY = 'oxicloud_first_run_completed'; @@ -491,24 +489,18 @@ let authInitialized = false; // Case 1: High refresh attempts if (refreshAttempts > 3) { console.error('EMERGENCY: Detected severe token refresh loop. Cleaning all auth data.'); - localStorage.clear(); // Full localStorage clear to ensure we break the loop + localStorage.removeItem(USER_DATA_KEY); sessionStorage.clear(); localStorage.setItem('emergency_clean', 'true'); // Store timestamp of the cleanup for stability localStorage.setItem('last_emergency_clean', Date.now().toString()); - - // No alert to avoid overwhelming the user if this happens multiple times } // Case 2: We were redirected from app due to auth issues if (redirectSource === 'app') { console.log('Detected redirect from app, ensuring clean auth state'); - // Clear only auth-related data to ensure a clean login - localStorage.removeItem('oxicloud_token'); - localStorage.removeItem('oxicloud_refresh_token'); - localStorage.removeItem('oxicloud_token_expiry'); - + localStorage.removeItem(USER_DATA_KEY); // Reset counters sessionStorage.removeItem('redirect_count'); localStorage.setItem('refresh_attempts', '0'); @@ -520,9 +512,7 @@ let authInitialized = false; if (lastCleanup > 0 && timeSinceCleanup < 10000) { // Less than 10 seconds console.warn('Multiple auth problems in short time, clearing auth data'); - localStorage.removeItem('oxicloud_token'); - localStorage.removeItem('oxicloud_refresh_token'); - localStorage.removeItem('oxicloud_token_expiry'); + localStorage.removeItem(USER_DATA_KEY); } })(); @@ -549,7 +539,7 @@ document.addEventListener('DOMContentLoaded', () => { try { const resp = await fetch('/api/auth/oidc/exchange', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: JSON.stringify({ code: oidcCode }) }); @@ -559,35 +549,15 @@ document.addEventListener('DOMContentLoaded', () => { } const data = await resp.json(); - const token = data.access_token || data.token; - const refreshToken = data.refresh_token || data.refreshToken; - - if (token) { - localStorage.setItem(TOKEN_KEY, token); - if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - - // Parse JWT expiry - const tokenParts = token.split('.'); - if (tokenParts.length === 3) { - try { - const payload = JSON.parse(atob(tokenParts[1])); - if (payload.exp) { - const expiryDate = new Date(payload.exp * 1000); - if (!isNaN(expiryDate.getTime())) { - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString()); - } - } - } catch (e) { /* ignore parse errors */ } - } - - if (data.user) { - localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); - } - - // Redirect to main app - window.location.href = '/'; - return; + // Tokens are now set as HttpOnly cookies by the server. + // Just store user display data and redirect. + if (data.user) { + localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); } + + // Redirect to main app + window.location.href = '/'; + return; } catch (err) { console.error('OIDC exchange error:', err); } @@ -617,55 +587,30 @@ document.addEventListener('DOMContentLoaded', () => { (async () => { try { - // First check if the token is valid - const token = localStorage.getItem(TOKEN_KEY); - const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY); - - if (!token) { - console.log('No token found, user needs to login'); - // Clear any stale data - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); - localStorage.removeItem(USER_DATA_KEY); - return; // Stay on login page - } - - // Check if token expiry is valid and not expired + // Check if we already have a valid session (cookie-based). + // The HttpOnly cookie is sent automatically — just probe /api/auth/me. try { - const expiryDate = new Date(tokenExpiry); - if (!isNaN(expiryDate.getTime()) && expiryDate > new Date()) { - console.log(`Token valid until ${expiryDate.toLocaleString()}`); - // Token still valid, redirect to main app + const meResp = await fetch(ME_ENDPOINT, { method: 'GET', credentials: 'same-origin' }); + if (meResp.ok) { + console.log('Session still valid, redirecting to app'); + const userData = await meResp.json(); + localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); redirectToMainApp(); return; - } else { - console.log('Token expired or invalid date, attempting refresh'); } - } catch (dateError) { - console.error('Error parsing token expiry date:', dateError); - // Continue to refresh attempt - } - - // Token expired, try to refresh - const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - if (refreshToken) { - try { - console.log('Attempting to refresh expired token'); - await refreshAuthToken(refreshToken); + // 401 / other → try a silent refresh + console.log('Session check returned', meResp.status, '— trying refresh'); + const refreshOk = await refreshAuthToken(); + if (refreshOk) { console.log('Token refresh successful, redirecting to app'); redirectToMainApp(); - } catch (error) { - // Refresh failed, continue with login page - console.log('Token refresh failed, user needs to login again:', error.message); - // Clear any stale auth data - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); - localStorage.removeItem(USER_DATA_KEY); + return; } - } else { - console.log('No refresh token found, user needs to login'); + } catch (err) { + console.log('Session probe failed, showing login page:', err.message); } + // No valid session — stay on login page + localStorage.removeItem(USER_DATA_KEY); // Check if admin account exists (customize this as needed) const isFirstRun = await checkFirstRun(); @@ -694,61 +639,16 @@ if (isLoginPage && loginForm) { try { const data = await login(username, password); - // Store auth data - console.log("Login response:", data); // Log the response for debugging - - // Use the correct field names from our API response - const token = data.access_token || data.token; - const refreshToken = data.refresh_token || data.refreshToken; - - if (!token) { - throw new Error('Server did not return an access token'); - } - - localStorage.setItem(TOKEN_KEY, token); - localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - - // Extract expiration date from the JWT token - let parsedExpiry = false; - const tokenParts = token.split('.'); - if (tokenParts.length === 3) { - try { - const payload = JSON.parse(atob(tokenParts[1])); - if (payload.exp) { - // payload.exp is in seconds since epoch - const expiryDate = new Date(payload.exp * 1000); - - // Verify the date is valid - if (!isNaN(expiryDate.getTime())) { - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString()); - parsedExpiry = true; - console.log(`Token expires on: ${expiryDate.toLocaleString()}`); - } else { - console.warn('Invalid expiry date in token:', payload.exp); - } - } - } catch (e) { - console.error('Error parsing JWT token:', e); - } - } - - // If we couldn't parse the expiry, set a default (30 days) - if (!parsedExpiry) { - console.log('Setting default token expiry (30 days)'); - const expiryTime = new Date(); - expiryTime.setDate(expiryTime.getDate() + 30); // 30 days instead of 1 hour - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString()); - } + // Tokens are now set as HttpOnly cookies by the server. + // Just store non-sensitive user data for display. + console.log('Login succeeded'); // Reset redirect counter on successful login sessionStorage.removeItem('redirect_count'); + localStorage.setItem('refresh_attempts', '0'); - // Fetch and store user data from the response if (data.user) { - console.log("Storing user data from server"); localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); - } else { - console.warn("Server did not return user data — will fetch from /me endpoint after redirect"); } // Redirect to main app @@ -871,7 +771,8 @@ async function login(username, password) { const response = await fetch(LOGIN_ENDPOINT, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...getCsrfHeaders() }, body: JSON.stringify({ username, password }), signal: controller.signal @@ -917,7 +818,8 @@ async function register(username, email, password, role = 'user') { const response = await fetch(REGISTER_ENDPOINT, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...getCsrfHeaders() }, body: JSON.stringify({ username, email, password, role }) }); @@ -951,15 +853,13 @@ async function register(username, email, password, role = 'user') { } /** - * Fetch current user data + * Fetch current user data — relies on HttpOnly cookie (auto-sent). */ -async function fetchUserData(token) { +async function fetchUserData() { try { const response = await fetch(ME_ENDPOINT, { method: 'GET', - headers: { - 'Authorization': `Bearer ${token}` - } + credentials: 'same-origin' }); if (!response.ok) { @@ -974,64 +874,47 @@ async function fetchUserData(token) { } /** - * Refresh authentication token - MAJOR CHANGE: Reduced functionality to break token loop + * Refresh authentication token via the server's refresh endpoint. + * The refresh-token cookie is sent automatically (HttpOnly, Path=/api/auth). + * Returns true on success, false on failure. */ -async function refreshAuthToken(refreshToken) { +async function refreshAuthToken() { try { - // Check if we're in a refresh loop + // Loop-breaker const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0'); localStorage.setItem('refresh_attempts', (refreshAttempts + 1).toString()); if (refreshAttempts > 3) { - console.error('Refresh token loop detected, clearing all auth data'); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); + console.error('Refresh token loop detected, giving up'); localStorage.removeItem(USER_DATA_KEY); localStorage.removeItem('refresh_attempts'); sessionStorage.removeItem('redirect_count'); - throw new Error('Too many refresh attempts, forcing login'); + return false; } - if (!refreshToken) { - throw new Error('No refresh token available'); - } + console.log('Attempting to refresh token (cookie-based)'); - console.log("Attempting to refresh token"); - - // Timeout for safety const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(REFRESH_ENDPOINT, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refresh_token: refreshToken }), + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: '{}', signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { - console.warn(`Refresh token failed with status: ${response.status}`); - throw new Error(`Token refresh failed: ${response.status}`); + console.warn('Refresh failed with status:', response.status); + return false; } const data = await response.json(); - console.log("Refresh token response:", data); - // Default expiry if we can't extract from token (30 days) - const expiryTime = new Date(); - expiryTime.setDate(expiryTime.getDate() + 30); - - // Update stored tokens minimally to avoid parsing issues - localStorage.setItem(TOKEN_KEY, data.access_token || data.token); - localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token || data.refreshToken || refreshToken); - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString()); - - // Store user data if provided + // Store user display data if provided if (data.user) { localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); } @@ -1040,17 +923,13 @@ async function refreshAuthToken(refreshToken) { localStorage.setItem('refresh_attempts', '0'); sessionStorage.removeItem('redirect_count'); - return data; + return true; } catch (error) { console.error('Token refresh error:', error); - // Clear stored auth data on refresh failure - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); localStorage.removeItem('refresh_attempts'); sessionStorage.removeItem('redirect_count'); - throw error; + return false; } } @@ -1075,32 +954,13 @@ async function checkFirstRun() { } /** - * Redirect to main application + * Redirect to main application — no token check needed (cookies are opaque). */ function redirectToMainApp() { console.log('Redirecting to main application'); - try { - // Reset refresh attempts counter on redirection localStorage.setItem('refresh_attempts', '0'); sessionStorage.removeItem('redirect_count'); - - // Set a token expiry if none exists - const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY); - if (!tokenExpiry) { - const expiryTime = new Date(); - expiryTime.setDate(expiryTime.getDate() + 30); - localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString()); - } - - // Verify we have a valid token before redirecting - const hasToken = localStorage.getItem(TOKEN_KEY); - if (!hasToken) { - console.error('No token found, cannot redirect to app'); - return; - } - - // Navigate to the main app window.location.replace('/'); } catch (error) { console.error('Error during redirect:', error); @@ -1109,12 +969,15 @@ function redirectToMainApp() { } /** - * Logout - clear tokens and redirect to login + * Logout — tell the server to clear HttpOnly cookies, then redirect. */ -function logout() { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(TOKEN_EXPIRY_KEY); +async function logout() { + try { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() }); + } catch (e) { + console.warn('Logout request failed:', e); + } localStorage.removeItem(USER_DATA_KEY); + localStorage.removeItem('refresh_attempts'); window.location.href = '/login'; } \ No newline at end of file diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 8c63e75f..b610ad76 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -127,9 +127,7 @@ const contextMenus = { if (window.app.contextMenuTargetFile) { // Capture reference before context menu cleanup nullifies it const file = window.app.contextMenuTargetFile; - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - fetch(`/api/files/${file.id}?metadata=true`, { headers }) + fetch(`/api/files/${file.id}?metadata=true`, { credentials: 'same-origin' }) .then(response => response.json()) .then(fileDetails => { // Check if viewable file type (images, PDFs, text files) @@ -585,8 +583,6 @@ const contextMenus = { */ async loadMoveDialogFolders(parentFolderId) { try { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; // Ensure we have the home folder ID before proceeding if (!window.app.userHomeFolderId) { @@ -606,7 +602,7 @@ const contextMenus = { const url = `/api/folders/${effectiveParentId}/contents`; console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId); - const response = await fetch(url, { headers }); + const response = await fetch(url, { credentials: 'same-origin' }); if (!response.ok) { console.error('Failed to load folders:', response.status); return; @@ -1023,9 +1019,7 @@ const contextMenus = { }; try { - const token = localStorage.getItem('oxicloud_token'); - const headers = { 'Content-Type': 'application/json' }; - if (token) headers['Authorization'] = `Bearer ${token}`; + const headers = { 'Content-Type': 'application/json', ...getCsrfHeaders() }; const response = await fetch('/api/shares', { method: 'POST', diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index 009ccdfe..a5ab106c 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -4,16 +4,12 @@ */ /** - * Get authorization headers for API requests - * @returns {Object} Headers object with Authorization bearer token + * Get authorization headers for API requests. + * Tokens are now in HttpOnly cookies — no explicit Authorization header needed. + * @returns {Object} Headers object */ function getAuthHeaders() { - const token = localStorage.getItem('oxicloud_token'); - const headers = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return headers; + return { ...getCsrfHeaders() }; } // File Operations Module @@ -177,10 +173,11 @@ const fileOps = { xhr.open('POST', '/api/files/upload'); - // Set auth header - const token = localStorage.getItem('oxicloud_token'); - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + // Auth is handled by HttpOnly cookies — no explicit header needed xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + // CSRF double-submit: echo the CSRF cookie as a request header + const _csrfTok = getCsrfToken(); + if (_csrfTok) xhr.setRequestHeader('X-CSRF-Token', _csrfTok); try { xhr.send(formData); diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index 8bd37ba2..610f7af6 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -210,10 +210,7 @@ class InlineViewer { try { console.log('Creating text viewer for:', file.name); - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - - const response = await fetch(`/api/files/${file.id}?inline=true`, { headers }); + const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' }); if (!response.ok) { throw new Error(`Error fetching file: ${response.status} ${response.statusText}`); @@ -254,12 +251,7 @@ class InlineViewer { const xhr = new XMLHttpRequest(); xhr.open('GET', `/api/files/${file.id}?inline=true`, true); xhr.responseType = 'blob'; - - // Add auth header - const token = localStorage.getItem('oxicloud_token'); - if (token) { - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - } + xhr.withCredentials = true; // Create a promise to handle the XHR const response = await new Promise((resolve, reject) => { @@ -357,10 +349,8 @@ class InlineViewer { try { console.log(`Creating ${mediaType} player for:`, file.name); - // Fetch file with auth header (same pattern as images/PDFs) - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - const response = await fetch(`/api/files/${file.id}?inline=true`, { headers }); + // Fetch file (cookie auto-sent) + const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' }); if (!response.ok) { throw new Error(`Error fetching file: ${response.status} ${response.statusText}`); @@ -488,10 +478,7 @@ class InlineViewer { } downloadFile(file) { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - - fetch(`/api/files/${file.id}`, { headers }) + fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob(); diff --git a/static/js/features/files/wopiEditor.js b/static/js/features/files/wopiEditor.js index 035a8b62..d0ee83cc 100644 --- a/static/js/features/files/wopiEditor.js +++ b/static/js/features/files/wopiEditor.js @@ -60,12 +60,9 @@ class WopiEditor { * Fetch editor URL and WOPI token from the backend. */ async _getEditorUrl(fileId, action) { - var token = localStorage.getItem('oxicloud_token') || ''; var response = await fetch( '/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action), - { - headers: { 'Authorization': 'Bearer ' + token } - } + { credentials: 'same-origin' } ); if (!response.ok) { var text = await response.text(); diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 97d06773..6dec12d8 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -16,10 +16,7 @@ const favorites = { // ───────────────────── helpers ───────────────────── _authHeaders() { - const token = localStorage.getItem('oxicloud_token'); - const h = {}; - if (token) h['Authorization'] = `Bearer ${token}`; - return h; + return { ...getCsrfHeaders() }; }, _cacheKey(id, type) { diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 9671b5e6..c312e027 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -13,10 +13,7 @@ const recent = { // ───────────────────── helpers ───────────────────── _authHeaders() { - const token = localStorage.getItem('oxicloud_token'); - const h = {}; - if (token) h['Authorization'] = `Bearer ${token}`; - return h; + return { ...getCsrfHeaders() }; }, // ───────────────────── lifecycle ───────────────────── diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js index d47a305c..e95b89d9 100644 --- a/static/js/features/sharing/fileSharing.js +++ b/static/js/features/sharing/fileSharing.js @@ -5,11 +5,9 @@ */ const fileSharing = { - /** Auth header helper */ + /** Auth header helper — tokens are in HttpOnly cookies now */ _headers(json = true) { - const h = {}; - const token = localStorage.getItem('oxicloud_token'); - if (token) h['Authorization'] = `Bearer ${token}`; + const h = { ...getCsrfHeaders() }; if (json) h['Content-Type'] = 'application/json'; return h; }, diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 76d6af9e..e0951e93 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -1,5 +1,4 @@ const API = '/api'; -const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token'); let currentAdminId = ''; let usersPage = 0; const PAGE_SIZE = 50; @@ -24,7 +23,7 @@ function showElement(id, mode = 'block') { } function headers() { - return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; + return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; } function formatBytes(bytes) { @@ -357,7 +356,6 @@ async function saveOidcSettings() { } async function init() { - if (!token) { showAccessDenied(); return; } try { const me = await fetch(API + '/auth/me', { headers: headers() }); if (!me.ok) { showAccessDenied(); return; } diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index 4a4008b7..21d1374e 100644 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -1,8 +1,7 @@ const API = '/api'; -const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token'); function headers() { - return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; + return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; } function formatBytes(bytes) { @@ -25,7 +24,6 @@ function timeAgo(dateStr) { } async function init() { - if (!token) { showError(); return; } try { const resp = await fetch(API + '/auth/me', { headers: headers() }); if (!resp.ok) { showError(); return; } diff --git a/static/js/views/shared/sharedView.js b/static/js/views/shared/sharedView.js index cb6e9937..2d28e6ee 100644 --- a/static/js/views/shared/sharedView.js +++ b/static/js/views/shared/sharedView.js @@ -9,11 +9,9 @@ const sharedView = { filteredItems: [], currentItem: null, - /** Auth header helper */ + /** Auth header helper — tokens are in HttpOnly cookies now */ _headers(json = false) { - const h = {}; - const token = localStorage.getItem('oxicloud_token'); - if (token) h['Authorization'] = `Bearer ${token}`; + const h = { ...getCsrfHeaders() }; if (json) h['Content-Type'] = 'application/json'; return h; }, diff --git a/static/login.html b/static/login.html index 1d55b704..0723c01b 100644 --- a/static/login.html +++ b/static/login.html @@ -13,6 +13,7 @@ + diff --git a/static/profile.html b/static/profile.html index c95eb062..fcc3c256 100644 --- a/static/profile.html +++ b/static/profile.html @@ -6,6 +6,7 @@ OxiCloud — My Profile +