diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b6ace6a7..37c386c5 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -16,25 +16,24 @@ 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 - let public_routes = Router::new() +/// Public auth routes — no authentication required. +pub fn auth_public_routes() -> Router> { + Router::new() .route("/status", get(get_system_status)) // OIDC endpoints (all public) .route("/oidc/providers", get(oidc_providers)) .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) - .route("/oidc/exchange", post(oidc_exchange)); + .route("/oidc/exchange", post(oidc_exchange)) +} - // Routes that DO require authentication - we use route_layer to apply middleware - // The middleware will use the state passed with .with_state() from main.rs - let protected_routes = Router::new() +/// Protected auth routes — require authentication (auth + CSRF middleware +/// must be applied by the caller in main.rs). +pub fn auth_protected_routes() -> Router> { + Router::new() .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) - .route("/logout", post(logout)); - - // Combine public and protected routes - public_routes.merge(protected_routes) + .route("/logout", post(logout)) } /// Rate-limited auth routes — split out so main.rs can apply per-endpoint diff --git a/src/main.rs b/src/main.rs index 90a39c4a..15796d87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -67,10 +66,7 @@ async fn main() -> Result<(), Box> { if !storage_path.exists() { std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory"); } - let locales_path = PathBuf::from("./static/locales"); - if !locales_path.exists() { - std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); - } + // Locales are embedded in the binary via rust-embed — no filesystem path needed. // Initialize database pools if auth is enabled let db_pools = if config.features.enable_auth { @@ -94,7 +90,7 @@ async fn main() -> Result<(), Box> { }; // Build all services via the factory - let factory = AppServiceFactory::with_config(storage_path, locales_path, config.clone()); + let factory = AppServiceFactory::with_config(storage_path, None, config.clone()); let app_state = factory.build_app_state(db_pools).await .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)"); @@ -171,7 +167,8 @@ async fn main() -> Result<(), Box> { } if config.features.enable_auth { use interfaces::api::handlers::auth_handler::{ - auth_routes, login_route, refresh_route, register_route, setup_route, + auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route, + setup_route, }; use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::api::handlers::device_auth_handler; @@ -227,8 +224,16 @@ async fn main() -> Result<(), Box> { rate_limit_refresh, )) .with_state(app_state.clone()); - // Remaining auth routes (status, OIDC, protected /me, /logout, etc.) - let auth_router = auth_routes().with_state(app_state.clone()); + // Public auth routes (status, OIDC) + let auth_public = auth_public_routes().with_state(app_state.clone()); + // Protected auth routes (/me, /change-password, /logout) — require auth + CSRF + let auth_protected = auth_protected_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -286,8 +291,10 @@ async fn main() -> Result<(), Box> { .nest("/api/auth", auth_login) .nest("/api/auth", auth_register) .nest("/api/auth", auth_refresh) - // Other auth endpoints (status, OIDC, protected /me, /logout) - .nest("/api/auth", auth_router) + // Public auth endpoints (status, OIDC) + .nest("/api/auth", auth_public) + // Protected auth endpoints (/me, /change-password, /logout) + .nest("/api/auth", auth_protected) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router) // Device Auth Grant public endpoints (authorize + token polling) diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index ff5a02ba..677e299a 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -65,7 +65,7 @@ function switchTab(name, el) { async function loadDashboard() { try { - const resp = await fetch(API + '/admin/dashboard', { headers: headers() }); + const resp = await fetch(API + '/admin/dashboard', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) return; const d = await resp.json(); document.getElementById('ds-total-users').textContent = d.total_users; @@ -103,7 +103,7 @@ async function loadUsers() { const tbody = document.getElementById('users-tbody'); tbody.innerHTML = ' Loading…'; try { - const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers() }); + const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { tbody.innerHTML = ' Failed to load users'; return; } const data = await resp.json(); totalUsers = data.total; @@ -151,7 +151,7 @@ async function toggleRole(userId, currentRole) { if (!confirm('Change role to ' + newRole + '?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/role', { - method: 'PUT', headers: headers(), body: JSON.stringify({ role: newRole }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ role: newRole }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } @@ -162,7 +162,7 @@ async function toggleActive(userId, currentActive) { if (!confirm('Are you sure you want to ' + action + ' this user?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/active', { - method: 'PUT', headers: headers(), body: JSON.stringify({ active: !currentActive }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ active: !currentActive }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } @@ -171,7 +171,7 @@ async function toggleActive(userId, currentActive) { async function deleteUser(userId, username) { if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return; try { - const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers() }); + const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } @@ -193,7 +193,7 @@ async function saveQuota() { const bytes = Math.round(val * unit); try { const resp = await fetch(API + '/admin/users/' + quotaUserId + '/quota', { - method: 'PUT', headers: headers(), body: JSON.stringify({ quota_bytes: bytes }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ quota_bytes: bytes }) }); if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } @@ -231,7 +231,7 @@ async function submitCreateUser() { btn.disabled = true; btn.innerHTML = ' Creating…'; try { const resp = await fetch(API + '/admin/users', { - method: 'POST', headers: headers(), + method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ username, password, email, role, quota_bytes: quotaBytes }) }); if (resp.ok) { @@ -271,7 +271,7 @@ async function submitResetPassword() { btn.disabled = true; btn.innerHTML = ' Resetting…'; try { const resp = await fetch(API + '/admin/users/' + resetPwUserId + '/password', { - method: 'PUT', headers: headers(), + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ new_password: password }) }); if (resp.ok) { closeResetPasswordModal(); } @@ -285,7 +285,7 @@ async function toggleRegistration(enabled) { else showElement('registration-warning', 'flex'); try { const resp = await fetch(API + '/admin/settings/registration', { - method: 'PUT', headers: headers(), + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ registration_enabled: enabled }) }); if (!resp.ok) { @@ -330,7 +330,7 @@ async function testConnection() { btn.disabled = true; btn.innerHTML = ' Discovering…'; const resultDiv = document.getElementById('discovery-result'); try { - const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), body: JSON.stringify({ issuer_url: url }) }); + const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ issuer_url: url }) }); const r = await resp.json(); if (r.success) { resultDiv.innerHTML = '
' + escapeHtml(r.message) + '
Issuer
' + escapeHtml(r.issuer||'—') + '
Auth Endpoint
' + escapeHtml(r.authorization_endpoint||'—') + '
'; @@ -357,7 +357,7 @@ async function saveOidcSettings() { provider_name: document.getElementById('provider-name').value.trim() || null, }; try { - const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), body: JSON.stringify(body) }); + const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) }); if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); } else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); } } catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); } @@ -366,13 +366,13 @@ async function saveOidcSettings() { async function init() { try { - const me = await fetch(API + '/auth/me', { headers: headers() }); + const me = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' }); if (!me.ok) { showAccessDenied(); return; } const user = await me.json(); if (user.role !== 'admin') { showAccessDenied(); return; } currentAdminId = user.id; - const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() }); + const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers(), credentials: 'same-origin' }); if (oidcResp.ok) { const s = await oidcResp.json(); document.getElementById('oidc-enabled').checked = s.enabled;