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:
@@ -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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
extract::{Json, Query, State},
|
extract::{Json, Query, State},
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect, Response},
|
||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -12,7 +12,9 @@ use crate::application::dtos::user_dto::{
|
|||||||
RefreshTokenDto, RegisterDto,
|
RefreshTokenDto, RegisterDto,
|
||||||
};
|
};
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::api::cookie_auth;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||||
|
|
||||||
pub fn auth_routes() -> Router<Arc<AppState>> {
|
pub fn auth_routes() -> Router<Arc<AppState>> {
|
||||||
// Routes that do NOT require authentication
|
// Routes that do NOT require authentication
|
||||||
@@ -103,7 +105,7 @@ async fn register(
|
|||||||
async fn login(
|
async fn login(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(dto): Json<LoginDto>,
|
Json(dto): Json<LoginDto>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// Add detailed logging for debugging
|
// Add detailed logging for debugging
|
||||||
tracing::info!("Login attempt for user: {}", dto.username);
|
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) => {
|
Err(err) => {
|
||||||
tracing::error!("Login failed for user {}: {}", dto.username, 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(
|
async fn refresh_token(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(dto): Json<RefreshTokenDto>,
|
headers: HeaderMap,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
body: axum::body::Bytes,
|
||||||
// Add rate limiting for token refresh to prevent refresh loops
|
) -> Result<Response, AppError> {
|
||||||
// Check if this refresh token is being used too frequently
|
|
||||||
|
|
||||||
// Log the refresh attempt for debugging
|
|
||||||
tracing::info!("Token refresh requested");
|
tracing::info!("Token refresh requested");
|
||||||
|
|
||||||
// Normal process for real tokens
|
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
.auth_service
|
.auth_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
.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::<RefreshTokenDto>(&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
|
let auth_response = auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.refresh_token(dto)
|
.refresh_token(dto)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Log successful token refresh
|
|
||||||
tracing::info!("Token refresh successful, new token issued");
|
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(
|
async fn get_current_user(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
CurrentUserId(user_id): CurrentUserId,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// Normal process for all users
|
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
.auth_service
|
.auth_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
.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
|
// First, update the storage usage statistics
|
||||||
// IMPORTANT: We await the calculation to return updated data
|
// IMPORTANT: We await the calculation to return updated data
|
||||||
if let Some(storage_usage_service) = state.storage_usage_service.as_ref() {
|
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(
|
async fn change_password(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
CurrentUserId(user_id): CurrentUserId,
|
||||||
Json(dto): Json<ChangePasswordDto>,
|
Json(dto): Json<ChangePasswordDto>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
@@ -255,22 +275,9 @@ async fn change_password(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
.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_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.change_password(&claims.sub, dto)
|
.change_password(&user_id, dto)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
@@ -279,32 +286,32 @@ async fn change_password(
|
|||||||
async fn logout(
|
async fn logout(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
CurrentUserId(user_id): CurrentUserId,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
.auth_service
|
.auth_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
.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
|
let token = headers
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
.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"))?;
|
.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_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.logout(&claims.sub, token)
|
.logout(&user_id, &token)
|
||||||
.await?;
|
.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
|
/// Get system status - returns whether admin is configured
|
||||||
@@ -452,7 +459,7 @@ async fn oidc_callback(
|
|||||||
async fn oidc_exchange(
|
async fn oidc_exchange(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<OidcExchangeDto>,
|
Json(body): Json<OidcExchangeDto>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let auth_service = state
|
let auth_service = state
|
||||||
.auth_service
|
.auth_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -471,5 +478,18 @@ async fn oidc_exchange(
|
|||||||
auth_response.user.username
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod cookie_auth;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use crate::common::di::AppState;
|
|||||||
// Re-export CurrentUser from application layer for use in handlers
|
// Re-export CurrentUser from application layer for use in handlers
|
||||||
pub use crate::application::dtos::user_dto::CurrentUser;
|
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
|
// Structure for use in Axum extractors
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
@@ -154,12 +160,15 @@ impl IntoResponse for AuthError {
|
|||||||
|
|
||||||
/// Secure authentication middleware.
|
/// Secure authentication middleware.
|
||||||
///
|
///
|
||||||
/// Supports two authentication methods:
|
/// Supports three authentication methods (tried in order):
|
||||||
/// 1. **Bearer JWT** — standard token in `Authorization: Bearer <token>`
|
/// 1. **Bearer JWT** — standard token in `Authorization: Bearer <token>`
|
||||||
/// 2. **Basic Auth with App Passwords** — for DAV clients (DAVx⁵, Thunderbird, rclone)
|
/// 2. **Basic Auth with App Passwords** — for DAV clients (DAVx⁵, Thunderbird, rclone)
|
||||||
/// that send `Authorization: Basic base64(username:app_password)`
|
/// 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(
|
pub async fn auth_middleware(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
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() {
|
if state.auth_service.is_none() {
|
||||||
tracing::error!("Auth middleware invoked but auth service is not configured");
|
tracing::error!("Auth middleware invoked but auth service is not configured");
|
||||||
return Err(AuthError::AuthServiceUnavailable);
|
return Err(AuthError::AuthServiceUnavailable);
|
||||||
|
|||||||
@@ -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<Response, Response> {
|
||||||
|
// 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::<CookieAuthenticated>().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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod csrf;
|
||||||
|
|||||||
+54
-2
@@ -11,6 +11,7 @@ use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use tower_http::limit::RequestBodyLimitLayer;
|
use tower_http::limit::RequestBodyLimitLayer;
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||||
|
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
||||||
|
|
||||||
let auth_router = auth_routes().with_state(app_state.clone());
|
let auth_router = auth_routes().with_state(app_state.clone());
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
||||||
let device_protected = device_auth_handler::device_auth_protected_routes()
|
let device_protected = device_auth_handler::device_auth_protected_routes()
|
||||||
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
app_state.clone(),
|
app_state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
@@ -190,6 +193,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// App Password management endpoints (protected — require JWT)
|
// App Password management endpoints (protected — require JWT)
|
||||||
let app_password_protected = app_password_handler::app_password_routes()
|
let app_password_protected = app_password_handler::app_password_routes()
|
||||||
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
app_state.clone(),
|
app_state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
@@ -197,7 +201,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
// Protected API routes — require valid JWT token
|
// Protected API routes — require valid JWT token
|
||||||
let protected_api = api_routes.layer(axum::middleware::from_fn_with_state(
|
let protected_api = api_routes
|
||||||
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
app_state.clone(),
|
app_state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
));
|
));
|
||||||
@@ -240,7 +246,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
|
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
|
||||||
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
||||||
let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state(
|
let wopi_api_protected = wopi_api
|
||||||
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
app_state.clone(),
|
app_state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
));
|
));
|
||||||
@@ -273,6 +281,50 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Without this Axum caps Multipart bodies at 2 MB.
|
// Without this Axum caps Multipart bodies at 2 MB.
|
||||||
app = app.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024));
|
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
|
||||||
|
// <script> blocks. TODO: migrate these to external .js files so
|
||||||
|
// 'unsafe-inline' can be removed.
|
||||||
|
// frame-src is permissive (*) to allow WOPI editor iframes whose
|
||||||
|
// origin is configured at runtime (Collabora, OnlyOffice, etc.).
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"default-src 'self'; \
|
||||||
|
script-src 'self' 'unsafe-inline'; \
|
||||||
|
style-src 'self' 'unsafe-inline'; \
|
||||||
|
img-src 'self' data: blob:; \
|
||||||
|
connect-src 'self'; \
|
||||||
|
font-src 'self' data:; \
|
||||||
|
frame-src *; \
|
||||||
|
frame-ancestors 'none'; \
|
||||||
|
base-uri 'self'; \
|
||||||
|
form-action 'self'"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("x-content-type-options"),
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("x-frame-options"),
|
||||||
|
HeaderValue::from_static("DENY"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("referrer-policy"),
|
||||||
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("permissions-policy"),
|
||||||
|
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||||
|
));
|
||||||
|
|
||||||
// Start server — tuned socket for low-latency responses
|
// Start server — tuned socket for low-latency responses
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], 8086));
|
let addr = SocketAddr::from(([0, 0, 0, 0], 8086));
|
||||||
tracing::info!("Starting OxiCloud server on http://{}", addr);
|
tracing::info!("Starting OxiCloud server on http://{}", addr);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>OxiCloud — Admin Panel</title>
|
<title>OxiCloud — Admin Panel</title>
|
||||||
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
|
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
|
||||||
<script src="/js/core/icons.js" defer></script>
|
<script src="/js/core/icons.js" defer></script>
|
||||||
|
<script src="/js/core/csrf.js" defer></script>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<link rel="stylesheet" href="/css/views/admin.css">
|
<link rel="stylesheet" href="/css/views/admin.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
<div id="status-error" class="status error" id="status-error-msg"></div>
|
<div id="status-error" class="status error" id="status-error-msg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/core/csrf.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
const codeInput = document.getElementById('user-code');
|
const codeInput = document.getElementById('user-code');
|
||||||
@@ -175,14 +176,13 @@
|
|||||||
|
|
||||||
async function lookupCode(code) {
|
async function lookupCode(code) {
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
|
||||||
if (!token) {
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (resp.status === 401) {
|
||||||
showError('You must be logged in to authorize a device. Please log in first.');
|
showError('You must be logged in to authorize a device. Please log in first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('Lookup failed');
|
if (!resp.ok) throw new Error('Lookup failed');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
@@ -210,13 +210,10 @@
|
|||||||
btnDeny.disabled = true;
|
btnDeny.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getAuthToken();
|
|
||||||
const resp = await fetch(`${API_BASE}/api/auth/device/verify`, {
|
const resp = await fetch(`${API_BASE}/api/auth/device/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'same-origin',
|
||||||
'Content-Type': 'application/json',
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ user_code: currentCode, action: action })
|
body: JSON.stringify({ user_code: currentCode, action: action })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,23 +239,6 @@
|
|||||||
errorText.textContent = msg;
|
errorText.textContent = msg;
|
||||||
errorText.style.display = 'block';
|
errorText.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthToken() {
|
|
||||||
// Try localStorage (OxiCloud frontend stores tokens there)
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('auth_token')
|
|
||||||
|| localStorage.getItem('access_token')
|
|
||||||
|| localStorage.getItem('oxicloud_token');
|
|
||||||
if (stored) return stored;
|
|
||||||
// Try parsing a JSON auth object
|
|
||||||
const authData = localStorage.getItem('auth');
|
|
||||||
if (authData) {
|
|
||||||
const parsed = JSON.parse(authData);
|
|
||||||
return parsed.access_token || parsed.token;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||||
<script defer src="/js/core/i18n.js"></script>
|
<script defer src="/js/core/i18n.js"></script>
|
||||||
|
<script defer src="/js/core/csrf.js"></script>
|
||||||
<script defer src="/js/core/languageSelector.js"></script>
|
<script defer src="/js/core/languageSelector.js"></script>
|
||||||
<script defer src="/js/core/notifications.js"></script>
|
<script defer src="/js/core/notifications.js"></script>
|
||||||
<script defer src="/js/core/modal.js"></script>
|
<script defer src="/js/core/modal.js"></script>
|
||||||
|
|||||||
@@ -3,25 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
async function refreshUserData() {
|
async function refreshUserData() {
|
||||||
const TOKEN_KEY = 'oxicloud_token';
|
|
||||||
const USER_DATA_KEY = 'oxicloud_user';
|
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 {
|
try {
|
||||||
console.log('Fetching /api/auth/me...');
|
console.log('Fetching /api/auth/me (cookie-based)...');
|
||||||
const response = await fetch('/api/auth/me', {
|
const response = await fetch('/api/auth/me', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'same-origin'
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('/api/auth/me response status:', response.status);
|
console.log('/api/auth/me response status:', response.status);
|
||||||
@@ -47,9 +35,6 @@ async function refreshUserData() {
|
|||||||
|
|
||||||
async function checkAuthentication() {
|
async function checkAuthentication() {
|
||||||
try {
|
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 USER_DATA_KEY = 'oxicloud_user';
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -60,8 +45,9 @@ async function checkAuthentication() {
|
|||||||
try {
|
try {
|
||||||
const exchangeResponse = await fetch('/api/auth/oidc/exchange', {
|
const exchangeResponse = await fetch('/api/auth/oidc/exchange', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
body: JSON.stringify({ code: oidcCode })
|
body: JSON.stringify({ code: oidcCode }),
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!exchangeResponse.ok) {
|
if (!exchangeResponse.ok) {
|
||||||
@@ -74,35 +60,7 @@ async function checkAuthentication() {
|
|||||||
const data = await exchangeResponse.json();
|
const data = await exchangeResponse.json();
|
||||||
console.log('OIDC token exchange successful');
|
console.log('OIDC token exchange successful');
|
||||||
|
|
||||||
const token = data.access_token || data.token;
|
// Tokens are now in HttpOnly cookies set by the server.
|
||||||
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) {
|
if (data.user) {
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
||||||
}
|
}
|
||||||
@@ -110,7 +68,6 @@ async function checkAuthentication() {
|
|||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('OIDC exchange error:', err);
|
console.error('OIDC exchange error:', err);
|
||||||
window.location.href = '/login?source=oidc_error';
|
window.location.href = '/login?source=oidc_error';
|
||||||
@@ -118,18 +75,12 @@ async function checkAuthentication() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
// Check session validity by calling /api/auth/me (cookie auto-sent)
|
||||||
|
console.log('Checking session via /api/auth/me...');
|
||||||
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');
|
|
||||||
|
|
||||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||||
if (userData.username) {
|
if (userData.username) {
|
||||||
|
// We have cached user data — render immediately, refresh in background
|
||||||
const userInitials = userData.username.substring(0, 2).toUpperCase();
|
const userInitials = userData.username.substring(0, 2).toUpperCase();
|
||||||
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => {
|
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => {
|
||||||
el.textContent = userInitials;
|
el.textContent = userInitials;
|
||||||
@@ -144,6 +95,15 @@ async function checkAuthentication() {
|
|||||||
refreshUserData().then(freshData => {
|
refreshUserData().then(freshData => {
|
||||||
if (freshData) {
|
if (freshData) {
|
||||||
console.log('Storage usage updated from server');
|
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 => {
|
}).catch(err => {
|
||||||
console.warn('Could not refresh user data:', err);
|
console.warn('Could not refresh user data:', err);
|
||||||
@@ -151,7 +111,8 @@ async function checkAuthentication() {
|
|||||||
|
|
||||||
resolveHomeFolder().then(() => window.loadFiles());
|
resolveHomeFolder().then(() => window.loadFiles());
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
const freshData = await refreshUserData();
|
const freshData = await refreshUserData();
|
||||||
if (freshData && freshData.username) {
|
if (freshData && freshData.username) {
|
||||||
@@ -161,26 +122,17 @@ async function checkAuthentication() {
|
|||||||
resolveHomeFolder().then(() => window.loadFiles());
|
resolveHomeFolder().then(() => window.loadFiles());
|
||||||
} else {
|
} else {
|
||||||
console.warn('Could not retrieve user data, redirecting to login');
|
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);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
window.location.href = '/login?source=invalid_session';
|
window.location.href = '/login?source=invalid_session';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch user data:', 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);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
window.location.href = '/login?source=session_error';
|
window.location.href = '/login?source=session_error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during authentication check:', 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');
|
localStorage.removeItem('oxicloud_user');
|
||||||
window.location.href = '/login?source=auth_error';
|
window.location.href = '/login?source=auth_error';
|
||||||
}
|
}
|
||||||
@@ -191,9 +143,7 @@ async function resolveHomeFolder() {
|
|||||||
|
|
||||||
if (app.userHomeFolderId) return;
|
if (app.userHomeFolderId) return;
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
const response = await fetch('/api/folders', { credentials: 'same-origin' });
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
const response = await fetch('/api/folders', { headers });
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(`Could not fetch home folder: ${response.status}`);
|
console.warn(`Could not fetch home folder: ${response.status}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -48,16 +48,13 @@ async function loadFiles(options = {}) {
|
|||||||
console.log(`Loading subfolder content: ${app.currentPath}`);
|
console.log(`Loading subfolder content: ${app.currentPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
'Pragma': 'no-cache'
|
'Pragma': 'no-cache'
|
||||||
};
|
};
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -224,18 +224,16 @@ function showUserProfileModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
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';
|
const USER_DATA_KEY = 'oxicloud_user';
|
||||||
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
// Tell the server to clear HttpOnly cookies
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() })
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
.catch(() => {}) // Best-effort
|
||||||
|
.finally(() => {
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
|
||||||
sessionStorage.removeItem('redirect_count');
|
sessionStorage.removeItem('redirect_count');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setupUserMenu = setupUserMenu;
|
window.setupUserMenu = setupUserMenu;
|
||||||
|
|||||||
@@ -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 } : {};
|
||||||
|
}
|
||||||
+61
-198
@@ -10,10 +10,8 @@ const REGISTER_ENDPOINT = `${API_URL}/register`;
|
|||||||
const ME_ENDPOINT = `${API_URL}/me`;
|
const ME_ENDPOINT = `${API_URL}/me`;
|
||||||
const REFRESH_ENDPOINT = `${API_URL}/refresh`;
|
const REFRESH_ENDPOINT = `${API_URL}/refresh`;
|
||||||
|
|
||||||
// Storage keys
|
// Storage keys — tokens are now in HttpOnly cookies (set by server).
|
||||||
const TOKEN_KEY = 'oxicloud_token';
|
// Only non-sensitive display data is kept in localStorage.
|
||||||
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
|
|
||||||
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
|
|
||||||
const USER_DATA_KEY = 'oxicloud_user';
|
const USER_DATA_KEY = 'oxicloud_user';
|
||||||
const LOCALE_KEY = 'oxicloud-locale';
|
const LOCALE_KEY = 'oxicloud-locale';
|
||||||
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
|
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
|
||||||
@@ -491,24 +489,18 @@ let authInitialized = false;
|
|||||||
// Case 1: High refresh attempts
|
// Case 1: High refresh attempts
|
||||||
if (refreshAttempts > 3) {
|
if (refreshAttempts > 3) {
|
||||||
console.error('EMERGENCY: Detected severe token refresh loop. Cleaning all auth data.');
|
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();
|
sessionStorage.clear();
|
||||||
localStorage.setItem('emergency_clean', 'true');
|
localStorage.setItem('emergency_clean', 'true');
|
||||||
|
|
||||||
// Store timestamp of the cleanup for stability
|
// Store timestamp of the cleanup for stability
|
||||||
localStorage.setItem('last_emergency_clean', Date.now().toString());
|
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
|
// Case 2: We were redirected from app due to auth issues
|
||||||
if (redirectSource === 'app') {
|
if (redirectSource === 'app') {
|
||||||
console.log('Detected redirect from app, ensuring clean auth state');
|
console.log('Detected redirect from app, ensuring clean auth state');
|
||||||
// Clear only auth-related data to ensure a clean login
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
localStorage.removeItem('oxicloud_token');
|
|
||||||
localStorage.removeItem('oxicloud_refresh_token');
|
|
||||||
localStorage.removeItem('oxicloud_token_expiry');
|
|
||||||
|
|
||||||
// Reset counters
|
// Reset counters
|
||||||
sessionStorage.removeItem('redirect_count');
|
sessionStorage.removeItem('redirect_count');
|
||||||
localStorage.setItem('refresh_attempts', '0');
|
localStorage.setItem('refresh_attempts', '0');
|
||||||
@@ -520,9 +512,7 @@ let authInitialized = false;
|
|||||||
|
|
||||||
if (lastCleanup > 0 && timeSinceCleanup < 10000) { // Less than 10 seconds
|
if (lastCleanup > 0 && timeSinceCleanup < 10000) { // Less than 10 seconds
|
||||||
console.warn('Multiple auth problems in short time, clearing auth data');
|
console.warn('Multiple auth problems in short time, clearing auth data');
|
||||||
localStorage.removeItem('oxicloud_token');
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
localStorage.removeItem('oxicloud_refresh_token');
|
|
||||||
localStorage.removeItem('oxicloud_token_expiry');
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -549,7 +539,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/auth/oidc/exchange', {
|
const resp = await fetch('/api/auth/oidc/exchange', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
body: JSON.stringify({ code: oidcCode })
|
body: JSON.stringify({ code: oidcCode })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,27 +549,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const token = data.access_token || data.token;
|
// Tokens are now set as HttpOnly cookies by the server.
|
||||||
const refreshToken = data.refresh_token || data.refreshToken;
|
// Just store user display data and redirect.
|
||||||
|
|
||||||
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) {
|
if (data.user) {
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
||||||
}
|
}
|
||||||
@@ -587,7 +558,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Redirect to main app
|
// Redirect to main app
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('OIDC exchange error:', err);
|
console.error('OIDC exchange error:', err);
|
||||||
}
|
}
|
||||||
@@ -617,55 +587,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// First check if the token is valid
|
// Check if we already have a valid session (cookie-based).
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
// The HttpOnly cookie is sent automatically — just probe /api/auth/me.
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
const expiryDate = new Date(tokenExpiry);
|
const meResp = await fetch(ME_ENDPOINT, { method: 'GET', credentials: 'same-origin' });
|
||||||
if (!isNaN(expiryDate.getTime()) && expiryDate > new Date()) {
|
if (meResp.ok) {
|
||||||
console.log(`Token valid until ${expiryDate.toLocaleString()}`);
|
console.log('Session still valid, redirecting to app');
|
||||||
// Token still valid, redirect to main app
|
const userData = await meResp.json();
|
||||||
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
|
||||||
redirectToMainApp();
|
redirectToMainApp();
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
console.log('Token expired or invalid date, attempting refresh');
|
|
||||||
}
|
}
|
||||||
} catch (dateError) {
|
// 401 / other → try a silent refresh
|
||||||
console.error('Error parsing token expiry date:', dateError);
|
console.log('Session check returned', meResp.status, '— trying refresh');
|
||||||
// Continue to refresh attempt
|
const refreshOk = await refreshAuthToken();
|
||||||
}
|
if (refreshOk) {
|
||||||
|
|
||||||
// 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);
|
|
||||||
console.log('Token refresh successful, redirecting to app');
|
console.log('Token refresh successful, redirecting to app');
|
||||||
redirectToMainApp();
|
redirectToMainApp();
|
||||||
} catch (error) {
|
return;
|
||||||
// Refresh failed, continue with login page
|
}
|
||||||
console.log('Token refresh failed, user needs to login again:', error.message);
|
} catch (err) {
|
||||||
// Clear any stale auth data
|
console.log('Session probe failed, showing login page:', err.message);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
}
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
// No valid session — stay on login page
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('No refresh token found, user needs to login');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if admin account exists (customize this as needed)
|
// Check if admin account exists (customize this as needed)
|
||||||
const isFirstRun = await checkFirstRun();
|
const isFirstRun = await checkFirstRun();
|
||||||
@@ -694,61 +639,16 @@ if (isLoginPage && loginForm) {
|
|||||||
try {
|
try {
|
||||||
const data = await login(username, password);
|
const data = await login(username, password);
|
||||||
|
|
||||||
// Store auth data
|
// Tokens are now set as HttpOnly cookies by the server.
|
||||||
console.log("Login response:", data); // Log the response for debugging
|
// Just store non-sensitive user data for display.
|
||||||
|
console.log('Login succeeded');
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset redirect counter on successful login
|
// Reset redirect counter on successful login
|
||||||
sessionStorage.removeItem('redirect_count');
|
sessionStorage.removeItem('redirect_count');
|
||||||
|
localStorage.setItem('refresh_attempts', '0');
|
||||||
|
|
||||||
// Fetch and store user data from the response
|
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
console.log("Storing user data from server");
|
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
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
|
// Redirect to main app
|
||||||
@@ -871,7 +771,8 @@ async function login(username, password) {
|
|||||||
const response = await fetch(LOGIN_ENDPOINT, {
|
const response = await fetch(LOGIN_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
...getCsrfHeaders()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@@ -917,7 +818,8 @@ async function register(username, email, password, role = 'user') {
|
|||||||
const response = await fetch(REGISTER_ENDPOINT, {
|
const response = await fetch(REGISTER_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
...getCsrfHeaders()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, email, password, role })
|
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 {
|
try {
|
||||||
const response = await fetch(ME_ENDPOINT, {
|
const response = await fetch(ME_ENDPOINT, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
credentials: 'same-origin'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
try {
|
||||||
// Check if we're in a refresh loop
|
// Loop-breaker
|
||||||
const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0');
|
const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0');
|
||||||
localStorage.setItem('refresh_attempts', (refreshAttempts + 1).toString());
|
localStorage.setItem('refresh_attempts', (refreshAttempts + 1).toString());
|
||||||
|
|
||||||
if (refreshAttempts > 3) {
|
if (refreshAttempts > 3) {
|
||||||
console.error('Refresh token loop detected, clearing all auth data');
|
console.error('Refresh token loop detected, giving up');
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
localStorage.removeItem('refresh_attempts');
|
localStorage.removeItem('refresh_attempts');
|
||||||
sessionStorage.removeItem('redirect_count');
|
sessionStorage.removeItem('redirect_count');
|
||||||
throw new Error('Too many refresh attempts, forcing login');
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!refreshToken) {
|
console.log('Attempting to refresh token (cookie-based)');
|
||||||
throw new Error('No refresh token available');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Attempting to refresh token");
|
|
||||||
|
|
||||||
// Timeout for safety
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
const response = await fetch(REFRESH_ENDPOINT, {
|
const response = await fetch(REFRESH_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'same-origin',
|
||||||
'Content-Type': 'application/json'
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
},
|
body: '{}',
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(`Refresh token failed with status: ${response.status}`);
|
console.warn('Refresh failed with status:', response.status);
|
||||||
throw new Error(`Token refresh failed: ${response.status}`);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("Refresh token response:", data);
|
|
||||||
|
|
||||||
// Default expiry if we can't extract from token (30 days)
|
// Store user display data if provided
|
||||||
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
|
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
|
||||||
}
|
}
|
||||||
@@ -1040,17 +923,13 @@ async function refreshAuthToken(refreshToken) {
|
|||||||
localStorage.setItem('refresh_attempts', '0');
|
localStorage.setItem('refresh_attempts', '0');
|
||||||
sessionStorage.removeItem('redirect_count');
|
sessionStorage.removeItem('redirect_count');
|
||||||
|
|
||||||
return data;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token refresh error:', 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(USER_DATA_KEY);
|
||||||
localStorage.removeItem('refresh_attempts');
|
localStorage.removeItem('refresh_attempts');
|
||||||
sessionStorage.removeItem('redirect_count');
|
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() {
|
function redirectToMainApp() {
|
||||||
console.log('Redirecting to main application');
|
console.log('Redirecting to main application');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Reset refresh attempts counter on redirection
|
|
||||||
localStorage.setItem('refresh_attempts', '0');
|
localStorage.setItem('refresh_attempts', '0');
|
||||||
sessionStorage.removeItem('redirect_count');
|
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('/');
|
window.location.replace('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during redirect:', 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() {
|
async function logout() {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
try {
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() });
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
} catch (e) {
|
||||||
|
console.warn('Logout request failed:', e);
|
||||||
|
}
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
localStorage.removeItem('refresh_attempts');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
@@ -127,9 +127,7 @@ const contextMenus = {
|
|||||||
if (window.app.contextMenuTargetFile) {
|
if (window.app.contextMenuTargetFile) {
|
||||||
// Capture reference before context menu cleanup nullifies it
|
// Capture reference before context menu cleanup nullifies it
|
||||||
const file = window.app.contextMenuTargetFile;
|
const file = window.app.contextMenuTargetFile;
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
fetch(`/api/files/${file.id}?metadata=true`, { credentials: 'same-origin' })
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
fetch(`/api/files/${file.id}?metadata=true`, { headers })
|
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(fileDetails => {
|
.then(fileDetails => {
|
||||||
// Check if viewable file type (images, PDFs, text files)
|
// Check if viewable file type (images, PDFs, text files)
|
||||||
@@ -585,8 +583,6 @@ const contextMenus = {
|
|||||||
*/
|
*/
|
||||||
async loadMoveDialogFolders(parentFolderId) {
|
async loadMoveDialogFolders(parentFolderId) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
|
|
||||||
// Ensure we have the home folder ID before proceeding
|
// Ensure we have the home folder ID before proceeding
|
||||||
if (!window.app.userHomeFolderId) {
|
if (!window.app.userHomeFolderId) {
|
||||||
@@ -606,7 +602,7 @@ const contextMenus = {
|
|||||||
const url = `/api/folders/${effectiveParentId}/contents`;
|
const url = `/api/folders/${effectiveParentId}/contents`;
|
||||||
|
|
||||||
console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId);
|
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) {
|
if (!response.ok) {
|
||||||
console.error('Failed to load folders:', response.status);
|
console.error('Failed to load folders:', response.status);
|
||||||
return;
|
return;
|
||||||
@@ -1023,9 +1019,7 @@ const contextMenus = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
const headers = { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const response = await fetch('/api/shares', {
|
const response = await fetch('/api/shares', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -4,16 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authorization headers for API requests
|
* Get authorization headers for API requests.
|
||||||
* @returns {Object} Headers object with Authorization bearer token
|
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
|
||||||
|
* @returns {Object} Headers object
|
||||||
*/
|
*/
|
||||||
function getAuthHeaders() {
|
function getAuthHeaders() {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
return { ...getCsrfHeaders() };
|
||||||
const headers = {};
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File Operations Module
|
// File Operations Module
|
||||||
@@ -177,10 +173,11 @@ const fileOps = {
|
|||||||
|
|
||||||
xhr.open('POST', '/api/files/upload');
|
xhr.open('POST', '/api/files/upload');
|
||||||
|
|
||||||
// Set auth header
|
// Auth is handled by HttpOnly cookies — no explicit header needed
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
||||||
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
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 {
|
try {
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
|
|||||||
@@ -210,10 +210,7 @@ class InlineViewer {
|
|||||||
try {
|
try {
|
||||||
console.log('Creating text viewer for:', file.name);
|
console.log('Creating text viewer for:', file.name);
|
||||||
|
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' });
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
|
|
||||||
const response = await fetch(`/api/files/${file.id}?inline=true`, { headers });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
||||||
@@ -254,12 +251,7 @@ class InlineViewer {
|
|||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', `/api/files/${file.id}?inline=true`, true);
|
xhr.open('GET', `/api/files/${file.id}?inline=true`, true);
|
||||||
xhr.responseType = 'blob';
|
xhr.responseType = 'blob';
|
||||||
|
xhr.withCredentials = true;
|
||||||
// Add auth header
|
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
if (token) {
|
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a promise to handle the XHR
|
// Create a promise to handle the XHR
|
||||||
const response = await new Promise((resolve, reject) => {
|
const response = await new Promise((resolve, reject) => {
|
||||||
@@ -357,10 +349,8 @@ class InlineViewer {
|
|||||||
try {
|
try {
|
||||||
console.log(`Creating ${mediaType} player for:`, file.name);
|
console.log(`Creating ${mediaType} player for:`, file.name);
|
||||||
|
|
||||||
// Fetch file with auth header (same pattern as images/PDFs)
|
// Fetch file (cookie auto-sent)
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' });
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
const response = await fetch(`/api/files/${file.id}?inline=true`, { headers });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
||||||
@@ -488,10 +478,7 @@ class InlineViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(file) {
|
downloadFile(file) {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
|
||||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
||||||
|
|
||||||
fetch(`/api/files/${file.id}`, { headers })
|
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
|
|||||||
@@ -60,12 +60,9 @@ class WopiEditor {
|
|||||||
* Fetch editor URL and WOPI token from the backend.
|
* Fetch editor URL and WOPI token from the backend.
|
||||||
*/
|
*/
|
||||||
async _getEditorUrl(fileId, action) {
|
async _getEditorUrl(fileId, action) {
|
||||||
var token = localStorage.getItem('oxicloud_token') || '';
|
|
||||||
var response = await fetch(
|
var response = await fetch(
|
||||||
'/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action),
|
'/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action),
|
||||||
{
|
{ credentials: 'same-origin' }
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
var text = await response.text();
|
var text = await response.text();
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ const favorites = {
|
|||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
return { ...getCsrfHeaders() };
|
||||||
const h = {};
|
|
||||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
||||||
return h;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_cacheKey(id, type) {
|
_cacheKey(id, type) {
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ const recent = {
|
|||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
return { ...getCsrfHeaders() };
|
||||||
const h = {};
|
|
||||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
||||||
return h;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ───────────────────── lifecycle ─────────────────────
|
// ───────────────────── lifecycle ─────────────────────
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fileSharing = {
|
const fileSharing = {
|
||||||
/** Auth header helper */
|
/** Auth header helper — tokens are in HttpOnly cookies now */
|
||||||
_headers(json = true) {
|
_headers(json = true) {
|
||||||
const h = {};
|
const h = { ...getCsrfHeaders() };
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
||||||
if (json) h['Content-Type'] = 'application/json';
|
if (json) h['Content-Type'] = 'application/json';
|
||||||
return h;
|
return h;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const API = '/api';
|
const API = '/api';
|
||||||
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
|
|
||||||
let currentAdminId = '';
|
let currentAdminId = '';
|
||||||
let usersPage = 0;
|
let usersPage = 0;
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
@@ -24,7 +23,7 @@ function showElement(id, mode = 'block') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@@ -357,7 +356,6 @@ async function saveOidcSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!token) { showAccessDenied(); return; }
|
|
||||||
try {
|
try {
|
||||||
const me = await fetch(API + '/auth/me', { headers: headers() });
|
const me = await fetch(API + '/auth/me', { headers: headers() });
|
||||||
if (!me.ok) { showAccessDenied(); return; }
|
if (!me.ok) { showAccessDenied(); return; }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const API = '/api';
|
const API = '/api';
|
||||||
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
|
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@@ -25,7 +24,6 @@ function timeAgo(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!token) { showError(); return; }
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API + '/auth/me', { headers: headers() });
|
const resp = await fetch(API + '/auth/me', { headers: headers() });
|
||||||
if (!resp.ok) { showError(); return; }
|
if (!resp.ok) { showError(); return; }
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ const sharedView = {
|
|||||||
filteredItems: [],
|
filteredItems: [],
|
||||||
currentItem: null,
|
currentItem: null,
|
||||||
|
|
||||||
/** Auth header helper */
|
/** Auth header helper — tokens are in HttpOnly cookies now */
|
||||||
_headers(json = false) {
|
_headers(json = false) {
|
||||||
const h = {};
|
const h = { ...getCsrfHeaders() };
|
||||||
const token = localStorage.getItem('oxicloud_token');
|
|
||||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
||||||
if (json) h['Content-Type'] = 'application/json';
|
if (json) h['Content-Type'] = 'application/json';
|
||||||
return h;
|
return h;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/js/core/i18n.js"></script>
|
<script src="/js/core/i18n.js"></script>
|
||||||
|
<script src="/js/core/csrf.js"></script>
|
||||||
<script src="/js/core/icons.js" defer></script>
|
<script src="/js/core/icons.js" defer></script>
|
||||||
<script src="/js/features/auth/auth.js" defer></script>
|
<script src="/js/features/auth/auth.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>OxiCloud — My Profile</title>
|
<title>OxiCloud — My Profile</title>
|
||||||
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
|
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
|
||||||
<script src="/js/core/icons.js" defer></script>
|
<script src="/js/core/icons.js" defer></script>
|
||||||
|
<script src="/js/core/csrf.js" defer></script>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<link rel="stylesheet" href="/css/views/profile.css">
|
<link rel="stylesheet" href="/css/views/profile.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user