From 06b3f28df0b395efa28579620b7da3fde8ab611d Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 23 Apr 2025 09:53:21 -0400 Subject: [PATCH] New fetchWithCsrf with fallback for session change. start.sh session directory added. --- CHANGELOG.md | 17 +- custom-php.ini | 1 + public/js/auth.js | 73 +++++- public/js/authModals.js | 2 +- public/js/folderManager.js | 108 ++++---- public/js/main.js | 68 +++-- public/js/upload.js | 70 +++-- src/controllers/authController.php | 18 +- src/controllers/uploadController.php | 58 +++-- src/controllers/userController.php | 369 ++++++++++++++------------- start.sh | 4 + 11 files changed, 492 insertions(+), 296 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5cdd3..30b14c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Changes 4/23/2025 +## Changes 4/23/2025 1.2.4 **AuthModel** @@ -16,8 +16,19 @@ - Repopulates `$_SESSION['authenticated']`, `username`, `isAdmin`, `folderOnly`, `readOnly`, `disableUpload` from payload - Regenerates session ID and CSRF token, then immediately returns JSON and exits - - **Updated** `userController.php` - - Fixed totp isAdmin when session is missing but `remember_me_token` cookie present +- **Updated** `userController.php` + - Fixed totp isAdmin when session is missing but `remember_me_token` cookie present + +- **loadCsrfToken()** + - Now reads `X-CSRF-Token` response header first, falls back to JSON `csrf_token` if header absent + - Updates `window.csrfToken`, `window.SHARE_URL`, and `` tags with the new values +- **fetchWithCsrf(url, options)** + - Sends `credentials: 'include'` and current `X-CSRF-Token` on every request + - Handles “soft-failure” JSON (`{ csrf_expired: true, csrf_token }`): updates token and retries once without a 403 in DevTools + - On HTTP 403 fallback: reads new token from header or `/api/auth/token.php`, updates token, and retries once + +- **start.sh** +- Session directory setup ## Changes 4/22/2025 v1.2.3 diff --git a/custom-php.ini b/custom-php.ini index 6e1d2ae..e548645 100644 --- a/custom-php.ini +++ b/custom-php.ini @@ -41,6 +41,7 @@ upload_tmp_dir=/tmp session.gc_maxlifetime=1440 session.gc_probability=1 session.gc_divisor=100 +session.save_path = "/var/www/sessions" ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; Error Handling / Logging diff --git a/public/js/auth.js b/public/js/auth.js index ba59cc2..d2d6cee 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -44,6 +44,55 @@ function showToast(msgKey) { } window.showToast = showToast; +const originalFetch = window.fetch; + +/* + * @param {string} url + * @param {object} options + * @returns {Promise} + */ +export async function fetchWithCsrf(url, options = {}) { + options = { credentials: 'include', headers: {}, ...options }; + options.headers['X-CSRF-Token'] = window.csrfToken; + + // 1) First attempt using the original fetch + let res = await originalFetch(url, options); + + // 2) Soft‐failure JSON check (200 + {csrf_expired}) + if (res.ok && res.headers.get('content-type')?.includes('application/json')) { + const clone = res.clone(); + const data = await clone.json(); + if (data.csrf_expired) { + const newToken = data.csrf_token; + window.csrfToken = newToken; + document.querySelector('meta[name="csrf-token"]').content = newToken; + options.headers['X-CSRF-Token'] = newToken; + return originalFetch(url, options); + } + } + + // 3) HTTP 403 fallback + if (res.status === 403) { + let newToken = res.headers.get('X-CSRF-Token'); + if (!newToken) { + const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' }); + if (tokRes.ok) { + const body = await tokRes.json(); + newToken = body.csrf_token; + } + } + if (newToken) { + window.csrfToken = newToken; + document.querySelector('meta[name="csrf-token"]').content = newToken; + options.headers['X-CSRF-Token'] = newToken; + res = await originalFetch(url, options); + } + } + + return res; +} + + // wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows function openTOTPLoginModal() { originalOpenTOTPLoginModal(); @@ -236,6 +285,10 @@ function checkAuthentication(showLoginToast = true) { if (typeof data.totp_enabled !== "undefined") { localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); } + if (data.csrf_token) { + window.csrfToken = data.csrf_token; + document.querySelector('meta[name="csrf-token"]').content = data.csrf_token; + } updateAuthenticatedUI(data); return data; } else { @@ -277,11 +330,11 @@ async function submitLogin(data) { try { const perm = await sendRequest("/api/getUserPermissions.php", "GET"); if (perm && typeof perm === "object") { - localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false"); - localStorage.setItem("readOnly", perm.readOnly ? "true" : "false"); - localStorage.setItem("disableUpload",perm.disableUpload? "true" : "false"); + localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false"); + localStorage.setItem("readOnly", perm.readOnly ? "true" : "false"); + localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false"); } - } catch {} + } catch { } return window.location.reload(); } @@ -406,10 +459,10 @@ function initAuth() { } let url = "/api/addUser.php"; if (window.setupMode) url += "?setup=1"; - fetch(url, { + fetchWithCsrf(url, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) }) .then(response => response.json()) @@ -439,10 +492,10 @@ function initAuth() { } const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?"); if (!confirmed) return; - fetch("/api/removeUser.php", { + fetchWithCsrf("/api/removeUser.php", { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: usernameToRemove }) }) .then(response => response.json()) @@ -478,10 +531,10 @@ function initAuth() { return; } const data = { oldPassword, newPassword, confirmPassword }; - fetch("/api/changePassword.php", { + fetchWithCsrf("/api/changePassword.php", { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }) .then(response => response.json()) diff --git a/public/js/authModals.js b/public/js/authModals.js index 4dd47f2..8ae7881 100644 --- a/public/js/authModals.js +++ b/public/js/authModals.js @@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js'; import { t, applyTranslations, setLocale } from './i18n.js'; import { loadAdminConfigFunc } from './auth.js'; -const version = "v1.2.3"; // Update this version string as needed +const version = "v1.2.4"; // Update this version string as needed const adminTitle = `${t("admin_panel")} ${version}`; let lastLoginData = null; diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 4be16cf..cee7489 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -4,6 +4,8 @@ import { loadFileList } from './fileListView.js'; import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js'; import { t } from './i18n.js'; import { openFolderShareModal } from './folderShareModal.js'; +import { fetchWithCsrf } from './auth.js'; +import { loadCsrfToken } from './main.js'; /* ---------------------- Helper Functions (Data/State) @@ -337,7 +339,7 @@ export async function loadFolderTree(selectedFolder) { try { // Check if the user has folder-only permission. await checkUserFolderPermission(); - + // Determine effective root folder. const username = localStorage.getItem("username") || "root"; let effectiveRoot = "root"; @@ -351,14 +353,14 @@ export async function loadFolderTree(selectedFolder) { } else { window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root"; } - + // Build fetch URL. let fetchUrl = '/api/folder/getFolderList.php'; if (window.userFolderOnly) { fetchUrl += '?restricted=1'; } console.log("Fetching folder list from:", fetchUrl); - + // Fetch folder list from the server. const response = await fetch(fetchUrl); if (response.status === 401) { @@ -375,10 +377,10 @@ export async function loadFolderTree(selectedFolder) { } else if (Array.isArray(folderData)) { folders = folderData; } - + // Remove any global "root" entry. folders = folders.filter(folder => folder.toLowerCase() !== "root"); - + // If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself). if (window.userFolderOnly && effectiveRoot !== "root") { folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/")); @@ -386,16 +388,16 @@ export async function loadFolderTree(selectedFolder) { localStorage.setItem("lastOpenedFolder", effectiveRoot); window.currentFolder = effectiveRoot; } - + localStorage.setItem("lastOpenedFolder", window.currentFolder); - + // Render the folder tree. const container = document.getElementById("folderTreeContainer"); if (!container) { console.error("Folder tree container not found."); return; } - + let html = `
[-] ${effectiveLabel} @@ -405,35 +407,35 @@ export async function loadFolderTree(selectedFolder) { html += renderFolderTree(tree, "", "block"); } container.innerHTML = html; - + // Attach drag/drop event listeners. container.querySelectorAll(".folder-option").forEach(el => { el.addEventListener("dragover", folderDragOverHandler); el.addEventListener("dragleave", folderDragLeaveHandler); el.addEventListener("drop", folderDropHandler); }); - + if (selectedFolder) { window.currentFolder = selectedFolder; } localStorage.setItem("lastOpenedFolder", window.currentFolder); - + const titleEl = document.getElementById("fileListTitle"); titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")"; setupBreadcrumbDelegation(); loadFileList(window.currentFolder); - + const folderState = loadFolderTreeState(); if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") { expandTreePath(window.currentFolder); } - + const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`); if (selectedEl) { container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected")); selectedEl.classList.add("selected"); } - + container.querySelectorAll(".folder-option").forEach(el => { el.addEventListener("click", function (e) { e.stopPropagation(); @@ -448,7 +450,7 @@ export async function loadFolderTree(selectedFolder) { loadFileList(selected); }); }); - + const rootToggle = container.querySelector("#rootRow .folder-toggle"); if (rootToggle) { rootToggle.addEventListener("click", function (e) { @@ -471,7 +473,7 @@ export async function loadFolderTree(selectedFolder) { } }); } - + container.querySelectorAll(".folder-toggle").forEach(toggle => { toggle.addEventListener("click", function (e) { e.stopPropagation(); @@ -494,7 +496,7 @@ export async function loadFolderTree(selectedFolder) { } }); }); - + } catch (error) { console.error("Error loading folder tree:", error); } @@ -627,45 +629,53 @@ document.getElementById("cancelCreateFolder").addEventListener("click", function document.getElementById("newFolderName").value = ""; }); attachEnterKeyListener("createFolderModal", "submitCreateFolder"); -document.getElementById("submitCreateFolder").addEventListener("click", function () { +document.getElementById("submitCreateFolder").addEventListener("click", async () => { const folderInput = document.getElementById("newFolderName").value.trim(); - if (!folderInput) { - showToast("Please enter a folder name."); - return; + if (!folderInput) return showToast("Please enter a folder name."); + + const selectedFolder = window.currentFolder || "root"; + const parent = selectedFolder === "root" ? "" : selectedFolder; + + // 1) Guarantee fresh CSRF + try { + await loadCsrfToken(); + } catch { + return showToast("Could not refresh CSRF token. Please reload."); } - let selectedFolder = window.currentFolder || "root"; - let fullFolderName = folderInput; - if (selectedFolder && selectedFolder !== "root") { - fullFolderName = selectedFolder + "/" + folderInput; - } - const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); - fetch("/api/folder/createFolder.php", { + + // 2) Call with fetchWithCsrf + fetchWithCsrf("/api/folder/createFolder.php", { method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken - }, - body: JSON.stringify({ - folderName: folderInput, - parent: selectedFolder === "root" ? "" : selectedFolder - }) + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folderName: folderInput, parent }) }) - .then(response => response.json()) - .then(data => { - if (data.success) { - showToast("Folder created successfully!"); - window.currentFolder = fullFolderName; - localStorage.setItem("lastOpenedFolder", fullFolderName); - loadFolderList(fullFolderName); - } else { - showToast("Error: " + (data.error || "Could not create folder")); + .then(async res => { + if (!res.ok) { + // pull out a JSON error, or fallback to status text + let err; + try { + const j = await res.json(); + err = j.error || j.message || res.statusText; + } catch { + err = res.statusText; + } + throw new Error(err); } + return res.json(); + }) + .then(data => { + showToast("Folder created!"); + const full = parent ? `${parent}/${folderInput}` : folderInput; + window.currentFolder = full; + localStorage.setItem("lastOpenedFolder", full); + loadFolderList(full); + }) + .catch(e => { + showToast("Error creating folder: " + e.message); + }) + .finally(() => { document.getElementById("createFolderModal").style.display = "none"; document.getElementById("newFolderName").value = ""; - }) - .catch(error => { - console.error("Error creating folder:", error); - document.getElementById("createFolderModal").style.display = "none"; }); }); diff --git a/public/js/main.js b/public/js/main.js index 742ff74..bc17dbf 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,8 +1,10 @@ import { sendRequest } from './networkUtils.js'; import { toggleVisibility, toggleAllCheckboxes, updateFileActionButtons, showToast } from './domUtils.js'; -import { loadFolderTree } from './folderManager.js'; import { initUpload } from './upload.js'; -import { initAuth, checkAuthentication, loadAdminConfigFunc } from './auth.js'; +import { initAuth, fetchWithCsrf, checkAuthentication, loadAdminConfigFunc } from './auth.js'; +const _originalFetch = window.fetch; +window.fetch = fetchWithCsrf; +import { loadFolderTree } from './folderManager.js'; import { setupTrashRestoreDelete } from './trashRestoreDelete.js'; import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js'; import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js'; @@ -13,35 +15,53 @@ import { editFile, saveFile } from './fileEditor.js'; import { t, applyTranslations, setLocale } from './i18n.js'; // Remove the retry logic version and just use loadCsrfToken directly: -function loadCsrfToken() { - return fetch('/api/auth/token.php', { credentials: 'include' }) +/** + * Fetches the current CSRF token (and share URL), updates window globals + * and tags, and returns the data. + * + * @returns {Promise<{csrf_token: string, share_url: string}>} + */ +export function loadCsrfToken() { + return fetch('/api/auth/token.php', { + method: 'GET', + credentials: 'include' + }) .then(response => { if (!response.ok) { - throw new Error("Token fetch failed with status: " + response.status); + throw new Error(`Token fetch failed with status: ${response.status}`); } - return response.json(); + // Prefer header if set, otherwise fall back to body + const headerToken = response.headers.get('X-CSRF-Token'); + return response.json() + .then(body => ({ + csrf_token: headerToken || body.csrf_token, + share_url: body.share_url + })); }) - .then(data => { - window.csrfToken = data.csrf_token; - window.SHARE_URL = data.share_url; - - let metaCSRF = document.querySelector('meta[name="csrf-token"]'); - if (!metaCSRF) { - metaCSRF = document.createElement('meta'); - metaCSRF.name = 'csrf-token'; - document.head.appendChild(metaCSRF); - } - metaCSRF.setAttribute('content', data.csrf_token); + .then(({ csrf_token, share_url }) => { + // Update globals + window.csrfToken = csrf_token; + window.SHARE_URL = share_url; - let metaShare = document.querySelector('meta[name="share-url"]'); - if (!metaShare) { - metaShare = document.createElement('meta'); - metaShare.name = 'share-url'; - document.head.appendChild(metaShare); + // Sync + let meta = document.querySelector('meta[name="csrf-token"]'); + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'csrf-token'; + document.head.appendChild(meta); } - metaShare.setAttribute('content', data.share_url); + meta.content = csrf_token; - return data; + // Sync + let shareMeta = document.querySelector('meta[name="share-url"]'); + if (!shareMeta) { + shareMeta = document.createElement('meta'); + shareMeta.name = 'share-url'; + document.head.appendChild(shareMeta); + } + shareMeta.content = share_url; + + return { csrf_token, share_url }; }); } diff --git a/public/js/upload.js b/public/js/upload.js index 671afef..1355ae3 100644 --- a/public/js/upload.js +++ b/public/js/upload.js @@ -412,7 +412,12 @@ function initResumableUpload() { forceChunkSize: true, testChunks: false, throttleProgressCallbacks: 1, - headers: { "X-CSRF-Token": window.csrfToken } + withCredentials: true, + headers: { 'X-CSRF-Token': window.csrfToken }, + query: { + folder: window.currentFolder || "root", + upload_token: window.csrfToken // still as a fallback + } }); const fileInput = document.getElementById("file"); @@ -496,26 +501,40 @@ function initResumableUpload() { }); resumableInstance.on("fileSuccess", function(file, message) { - const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`); + // Try to parse JSON response + let data; + try { + data = JSON.parse(message); + } catch (e) { + data = null; + } + + // 1) Soft‐fail CSRF? then update token & retry this file + if (data && data.csrf_expired) { + // Update global and Resumable headers + window.csrfToken = data.csrf_token; + resumableInstance.opts.headers['X-CSRF-Token'] = data.csrf_token; + resumableInstance.opts.query.upload_token = data.csrf_token; + // Retry this chunk/file + file.retry(); + return; + } + + // 2) Otherwise treat as real success: + const li = document.querySelector( + `li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]` + ); if (li && li.progressBar) { li.progressBar.style.width = "100%"; li.progressBar.innerText = "Done"; - // Hide pause/resume and remove buttons for successful files. + // remove action buttons const pauseResumeBtn = li.querySelector(".pause-resume-btn"); - if (pauseResumeBtn) { - pauseResumeBtn.style.display = "none"; - } + if (pauseResumeBtn) pauseResumeBtn.style.display = "none"; const removeBtn = li.querySelector(".remove-file-btn"); - if (removeBtn) { - removeBtn.style.display = "none"; - } - // Schedule removal of the file entry after 5 seconds. - setTimeout(() => { - li.remove(); - window.selectedFiles = window.selectedFiles.filter(f => f.uniqueIdentifier !== file.uniqueIdentifier); - updateFileInfoCount(); - }, 5000); + if (removeBtn) removeBtn.style.display = "none"; + setTimeout(() => li.remove(), 5000); } + loadFileList(window.currentFolder); }); @@ -618,8 +637,25 @@ function submitFiles(allFiles) { } catch (e) { jsonResponse = null; } + + // ─── Soft-fail CSRF: retry this upload ─────────────────────── + if (jsonResponse && jsonResponse.csrf_expired) { + console.warn("CSRF expired during upload, retrying chunk", file.uploadIndex); + // 1) update global token + header + window.csrfToken = jsonResponse.csrf_token; + xhr.open("POST", "/api/upload/upload.php", true); + xhr.withCredentials = true; + xhr.setRequestHeader("X-CSRF-Token", window.csrfToken); + // 2) re-send the same formData + xhr.send(formData); + return; // skip the "finishedCount++" and error/success logic for now + } + + // ─── Normal success/error handling ──────────────────────────── const li = progressElements[file.uploadIndex]; + if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { + // real success if (li) { li.progressBar.style.width = "100%"; li.progressBar.innerText = "Done"; @@ -627,11 +663,14 @@ function submitFiles(allFiles) { } uploadResults[file.uploadIndex] = true; } else { + // real failure if (li) { li.progressBar.innerText = "Error"; } allSucceeded = false; } + + // ─── Only now count this chunk as finished ─────────────────── finishedCount++; if (finishedCount === allFiles.length) { refreshFileList(allFiles, uploadResults, progressElements); @@ -665,6 +704,7 @@ function submitFiles(allFiles) { }); xhr.open("POST", "/api/upload/upload.php", true); + xhr.withCredentials = true; xhr.setRequestHeader("X-CSRF-Token", window.csrfToken); xhr.send(formData); }); diff --git a/src/controllers/authController.php b/src/controllers/authController.php index 3166ffa..18cc089 100644 --- a/src/controllers/authController.php +++ b/src/controllers/authController.php @@ -346,7 +346,9 @@ class AuthController if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) { $payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']); if ($payload) { + $old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32)); session_regenerate_id(true); + $_SESSION['csrf_token'] = $old; $_SESSION['authenticated'] = true; $_SESSION['username'] = $payload['username']; $_SESSION['isAdmin'] = !empty($payload['isAdmin']); @@ -354,7 +356,7 @@ class AuthController $_SESSION['readOnly'] = $payload['readOnly'] ?? false; $_SESSION['disableUpload'] = $payload['disableUpload'] ?? false; // regenerate CSRF if you use one - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + // TOTP enabled? (same logic as below) $usersFile = USERS_DIR . USERS_FILE; @@ -371,6 +373,7 @@ class AuthController echo json_encode([ 'authenticated' => true, + 'csrf_token' => $_SESSION['csrf_token'], 'isAdmin' => $_SESSION['isAdmin'], 'totp_enabled' => $totp, 'username' => $_SESSION['username'], @@ -446,10 +449,19 @@ class AuthController */ public function getToken(): void { + // 1) Ensure session and CSRF token exist + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + // 2) Emit headers header('Content-Type: application/json'); + header('X-CSRF-Token: ' . $_SESSION['csrf_token']); + + // 3) Return JSON payload echo json_encode([ - "csrf_token" => $_SESSION['csrf_token'], - "share_url" => SHARE_URL + 'csrf_token' => $_SESSION['csrf_token'], + 'share_url' => SHARE_URL ]); exit; } diff --git a/src/controllers/uploadController.php b/src/controllers/uploadController.php index eea9b65..f9bc2c8 100644 --- a/src/controllers/uploadController.php +++ b/src/controllers/uploadController.php @@ -72,34 +72,56 @@ class UploadController { */ public function handleUpload(): void { header('Content-Type: application/json'); - - // CSRF Protection. + + // + // 1) CSRF – pull from header or POST fields + // $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = $headersArr['x-csrf-token'] ?? ''; - if (!isset($_SESSION['csrf_token']) || trim($receivedToken) !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); + $received = ''; + if (!empty($headersArr['x-csrf-token'])) { + $received = trim($headersArr['x-csrf-token']); + } elseif (!empty($_POST['csrf_token'])) { + $received = trim($_POST['csrf_token']); + } elseif (!empty($_POST['upload_token'])) { + $received = trim($_POST['upload_token']); + } + + // 1a) If it doesn’t match, soft-fail: send new token and let client retry + if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { + // regenerate + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + // tell client “please retry with this new token” + http_response_code(200); + echo json_encode([ + 'csrf_expired' => true, + 'csrf_token' => $_SESSION['csrf_token'] + ]); exit; } - // Ensure user is authenticated. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + + // + // 2) Auth checks + // + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo json_encode(["error" => "Unauthorized"]); exit; } - // Check user permissions. - $username = $_SESSION['username'] ?? ''; - $userPermissions = loadUserPermissions($username); - if ($username && !empty($userPermissions['disableUpload'])) { + $userPerms = loadUserPermissions($_SESSION['username']); + if (!empty($userPerms['disableUpload'])) { http_response_code(403); echo json_encode(["error" => "Upload disabled for this user."]); exit; } - - // Delegate to the model. + + // + // 3) Delegate the actual file handling + // $result = UploadModel::handleUpload($_POST, $_FILES); - - // For chunked uploads, output JSON (e.g., "chunk uploaded" status). + + // + // 4) Respond + // if (isset($result['error'])) { http_response_code(400); echo json_encode($result); @@ -109,8 +131,8 @@ class UploadController { echo json_encode($result); exit; } - - // Otherwise, for full upload success, set a flash message and redirect. + + // full‐upload redirect $_SESSION['upload_message'] = "File uploaded successfully."; exit; } diff --git a/src/controllers/userController.php b/src/controllers/userController.php index fb7b1bc..c8a51db 100644 --- a/src/controllers/userController.php +++ b/src/controllers/userController.php @@ -87,63 +87,83 @@ class UserController public function addUser() { + // 1) Ensure JSON output and session header('Content-Type: application/json'); - $usersFile = USERS_DIR . USERS_FILE; + // 1a) Initialize CSRF token if missing + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } - // Determine if we're in setup mode. - // Setup mode means the "setup" query parameter is passed - // and users.txt is missing, empty, or contains only whitespace. - $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); - if ($isSetup && (!file_exists($usersFile) || filesize($usersFile) == 0 || trim(file_get_contents($usersFile)) === '')) { - // Allow initial admin creation without session or CSRF checks. + // 2) Determine setup mode (first-ever admin creation) + $usersFile = USERS_DIR . USERS_FILE; + $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); + $setupMode = false; + if ( + $isSetup && (! file_exists($usersFile) + || filesize($usersFile) === 0 + || trim(file_get_contents($usersFile)) === '' + ) + ) { $setupMode = true; } else { - $setupMode = false; - // In non-setup mode, perform CSRF token and authentication checks. - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : ''; - if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(["error" => "Invalid CSRF token"]); + // 3) In non-setup, enforce CSRF + auth checks + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $receivedToken = trim($headersArr['x-csrf-token'] ?? ''); + + // 3a) Soft-fail CSRF: on mismatch, regenerate and return new token + if ($receivedToken !== $_SESSION['csrf_token']) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + header('X-CSRF-Token: ' . $_SESSION['csrf_token']); + echo json_encode([ + 'csrf_expired' => true, + 'csrf_token' => $_SESSION['csrf_token'] + ]); exit; } + + // 3b) Must be logged in as admin if ( - !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || - !isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true + empty($_SESSION['authenticated']) + || $_SESSION['authenticated'] !== true + || empty($_SESSION['isAdmin']) + || $_SESSION['isAdmin'] !== true ) { echo json_encode(["error" => "Unauthorized"]); exit; } } - // Get the JSON input data. - $data = json_decode(file_get_contents("php://input"), true); - $newUsername = trim($data["username"] ?? ""); - $newPassword = trim($data["password"] ?? ""); + // 4) Parse input + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $newUsername = trim($data['username'] ?? ''); + $newPassword = trim($data['password'] ?? ''); - // In setup mode, force the new user to be an admin. + // 5) Determine admin flag if ($setupMode) { - $isAdmin = "1"; + $isAdmin = '1'; } else { - $isAdmin = !empty($data["isAdmin"]) ? "1" : "0"; + $isAdmin = !empty($data['isAdmin']) ? '1' : '0'; } - // Validate that a username and password are provided. - if (!$newUsername || !$newPassword) { + // 6) Validate fields + if ($newUsername === '' || $newPassword === '') { echo json_encode(["error" => "Username and password required"]); exit; } - - // Validate username format. if (!preg_match(REGEX_USER, $newUsername)) { - echo json_encode(["error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."]); + echo json_encode([ + "error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed." + ]); exit; } - // Delegate the business logic to the model. + // 7) Delegate to model $result = userModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); + + // 8) Return model result echo json_encode($result); + exit; } /** @@ -847,148 +867,151 @@ class UserController * ) */ - public function verifyTOTP() - { - header('Content-Type: application/json'); - header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); - - // Rate‑limit - if (!isset($_SESSION['totp_failures'])) { - $_SESSION['totp_failures'] = 0; - } - if ($_SESSION['totp_failures'] >= 5) { - http_response_code(429); - echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); - exit; - } - - // Must be authenticated OR pending login - if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) { - http_response_code(403); - echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); - exit; - } - - // CSRF check - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = $headersArr['x-csrf-token'] ?? ''; - if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); - exit; - } - - // Parse and validate input - $inputData = json_decode(file_get_contents("php://input"), true); - $code = trim($inputData['totp_code'] ?? ''); - if (!preg_match('/^\d{6}$/', $code)) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); - exit; - } - - // TFA helper - $tfa = new \RobThree\Auth\TwoFactorAuth( - new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), - 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 - ); - - // Pending‑login flow (first password step passed) - if (isset($_SESSION['pending_login_user'])) { - $username = $_SESSION['pending_login_user']; - $pendingSecret = $_SESSION['pending_login_secret'] ?? null; - $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; - - if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { - $_SESSION['totp_failures']++; - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); - exit; - } - - // === Issue “remember me” token if requested === - if ($rememberMe) { - $tokFile = USERS_DIR . 'persistent_tokens.json'; - $token = bin2hex(random_bytes(32)); - $expiry = time() + 30 * 24 * 60 * 60; - $all = []; - - if (file_exists($tokFile)) { - $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); - $all = json_decode($dec, true) ?: []; - } - $isAdmin = ((int)userModel::getUserRole($username) === 1); - $all[$token] = [ - 'username' => $username, - 'expiry' => $expiry, - 'isAdmin' => $isAdmin - ]; - file_put_contents( - $tokFile, - encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), - LOCK_EX - ); - - $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); - - // Persistent cookie - setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); - - // Re‑issue PHP session cookie - setcookie( - session_name(), - session_id(), - $expiry, - '/', - '', - $secure, - true - ); - } - - // Finalize login - session_regenerate_id(true); - $_SESSION['authenticated'] = true; - $_SESSION['username'] = $username; - $_SESSION['isAdmin'] = $isAdmin; - $_SESSION['folderOnly'] = loadUserPermissions($username); - - // Clean up - unset( - $_SESSION['pending_login_user'], - $_SESSION['pending_login_secret'], - $_SESSION['pending_login_remember_me'], - $_SESSION['totp_failures'] - ); - - echo json_encode(['status' => 'ok', 'message' => 'Login successful']); - exit; - } - - // Setup/verification flow (not pending) - $username = $_SESSION['username'] ?? ''; - if (!$username) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); - exit; - } - - $totpSecret = userModel::getTOTPSecret($username); - if (!$totpSecret) { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']); - exit; - } - - if (!$tfa->verifyCode($totpSecret, $code)) { - $_SESSION['totp_failures']++; - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); - exit; - } - - // Successful setup/verification - unset($_SESSION['totp_failures']); - echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); - } + public function verifyTOTP() + { + header('Content-Type: application/json'); + header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); + + // Rate‑limit + if (!isset($_SESSION['totp_failures'])) { + $_SESSION['totp_failures'] = 0; + } + if ($_SESSION['totp_failures'] >= 5) { + http_response_code(429); + echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); + exit; + } + + // Must be authenticated OR pending login + if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); + exit; + } + + // CSRF check + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $csrfHeader = $headersArr['x-csrf-token'] ?? ''; + if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); + exit; + } + + // Parse and validate input + $inputData = json_decode(file_get_contents("php://input"), true); + $code = trim($inputData['totp_code'] ?? ''); + if (!preg_match('/^\d{6}$/', $code)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); + exit; + } + + // TFA helper + $tfa = new \RobThree\Auth\TwoFactorAuth( + new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), + 'FileRise', + 6, + 30, + \RobThree\Auth\Algorithm::Sha1 + ); + + // Pending‑login flow (first password step passed) + if (isset($_SESSION['pending_login_user'])) { + $username = $_SESSION['pending_login_user']; + $pendingSecret = $_SESSION['pending_login_secret'] ?? null; + $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; + + if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { + $_SESSION['totp_failures']++; + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); + exit; + } + + // === Issue “remember me” token if requested === + if ($rememberMe) { + $tokFile = USERS_DIR . 'persistent_tokens.json'; + $token = bin2hex(random_bytes(32)); + $expiry = time() + 30 * 24 * 60 * 60; + $all = []; + + if (file_exists($tokFile)) { + $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); + $all = json_decode($dec, true) ?: []; + } + $isAdmin = ((int)userModel::getUserRole($username) === 1); + $all[$token] = [ + 'username' => $username, + 'expiry' => $expiry, + 'isAdmin' => $isAdmin + ]; + file_put_contents( + $tokFile, + encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), + LOCK_EX + ); + + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + + // Persistent cookie + setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); + + // Re‑issue PHP session cookie + setcookie( + session_name(), + session_id(), + $expiry, + '/', + '', + $secure, + true + ); + } + + // Finalize login + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = $isAdmin; + $_SESSION['folderOnly'] = loadUserPermissions($username); + + // Clean up + unset( + $_SESSION['pending_login_user'], + $_SESSION['pending_login_secret'], + $_SESSION['pending_login_remember_me'], + $_SESSION['totp_failures'] + ); + + echo json_encode(['status' => 'ok', 'message' => 'Login successful']); + exit; + } + + // Setup/verification flow (not pending) + $username = $_SESSION['username'] ?? ''; + if (!$username) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); + exit; + } + + $totpSecret = userModel::getTOTPSecret($username); + if (!$totpSecret) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']); + exit; + } + + if (!$tfa->verifyCode($totpSecret, $code)) { + $_SESSION['totp_failures']++; + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); + exit; + } + + // Successful setup/verification + unset($_SESSION['totp_failures']); + echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); + } } diff --git a/start.sh b/start.sh index b2fe67f..56a7934 100644 --- a/start.sh +++ b/start.sh @@ -25,6 +25,10 @@ mkdir -p /var/www/metadata/log chown www-data:www-data /var/www/metadata/log chmod 775 /var/www/metadata/log +mkdir -p /var/www/sessions +chown www-data:www-data /var/www/sessions +chmod 700 /var/www/sessions + # 2.2) Prepare other dynamic dirs for d in uploads users metadata; do tgt="/var/www/${d}"