fix(auth): apply auth middleware to /me, /change-password, /logout and add credentials to admin.js
The protected auth routes (/me, /change-password, /logout) were merged with public routes in auth_handler.rs but never had auth middleware applied in main.rs — so the CurrentUserId extractor always failed with 401. Split auth_routes() into auth_public_routes() and auth_protected_routes(), applying auth + CSRF middleware to the latter. Also added credentials: 'same-origin' to all 13 fetch calls in admin.js so the browser sends HttpOnly auth cookies with requests.
This commit is contained in:
@@ -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<Arc<AppState>> {
|
||||
// Routes that do NOT require authentication
|
||||
let public_routes = Router::new()
|
||||
/// Public auth routes — no authentication required.
|
||||
pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
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<Arc<AppState>> {
|
||||
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
|
||||
|
||||
+18
-11
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
};
|
||||
|
||||
// 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<dyn std::error::Error>> {
|
||||
}
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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)
|
||||
|
||||
@@ -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 = '<tr><td colspan="7" class="table-loading-cell"><i class="fas fa-spinner fa-spin"></i> Loading…</td></tr>';
|
||||
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 = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Failed to load users</td></tr>'; 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' + escapeHtml(r.message) + '</strong><dl><dt>Issuer</dt><dd>' + escapeHtml(r.issuer||'—') + '</dd><dt>Auth Endpoint</dt><dd>' + escapeHtml(r.authorization_endpoint||'—') + '</dd></dl></div>';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user