Overhaul networkUtils and expand auth

This commit is contained in:
Ryan
2025-04-17 01:20:18 -04:00
committed by GitHub
parent 75d3bf5a9b
commit 22cce5a898
7 changed files with 263 additions and 209 deletions

View File

@@ -26,6 +26,13 @@ This refactor improves maintainability, testability, and documentation clarity a
- Switched your share modal code to use a leading slash ("/api/file/share.php") so it generates absolute URLs instead of relative /share.php.
- In the sharedfolder gallery, adjusted the clientside image path to point at /uploads/... instead of /api/folder/uploads/...
- Updated both AdminModel defaults and the AuthController to use the exact full path
- Network Utilities Overhaul swapped out the old fetch wrapper for one that always reads the raw response, tries to JSON.parse it, and then either returns the parsed object on ok or throws it on error.
- Adjusted your submitLogin .catch() to grab the thrown object (or string) and pass that through to showToast, so now “Invalid credentials” actually shows up.
- Pulled the common sessionsetup and “remember me” logic into two new helpers, finalizeLogin() (for AJAX/form/basic/TOTP) and finishBrowserLogin() (for OIDC redirects). That removed tons of duplication and ensures every path calls the same permissionloading code.
- Ensured that after you POST just a totp_code, we pick up pending_login_user/pending_login_secret, verify it, then immediately call finalizeLogin().
- Expanded checkAuth.php Response now returns all three flags—folderOnly, readOnly, and disableUpload so client can handle every permission.
- In auth.jss updateAuthenticatedUI(), write all three flags into localStorage whenever you land on the app (OIDC, basic or form). That guarantees consistent behavior across page loads.
- Made sure the OIDC handler reads the live config via AdminModel::getConfig() and pushes you through the TOTP flow if needed, then back to /index.html.
---

View File

@@ -118,10 +118,10 @@ $cookieParams = [
'samesite' => 'Lax'
];
// At the very beginning of config.php
ini_set('session.save_path', __DIR__ . '/../sessions');
/*ini_set('session.save_path', __DIR__ . '/../sessions');
if (!is_dir(__DIR__ . '/../sessions')) {
mkdir(__DIR__ . '/../sessions', 0777, true);
}
}*/
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
@@ -164,7 +164,7 @@ define('BASE_URL', 'http://yourwebsite/uploads/');
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShareUrl = isset($_SERVER['HTTP_HOST'])
? "http://" . $_SERVER['HTTP_HOST'] . "/share.php"
? "http://" . $_SERVER['HTTP_HOST'] . "/api/file/share.php"
: "http://localhost/api/file/share.php";
} else {
$defaultShareUrl = rtrim(BASE_URL, '/') . "/api/file/share.php";

View File

@@ -15,7 +15,7 @@
<link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content="">
<meta name="share-url" content="/api/file/share.php">
<meta name="share-url" content="">
<!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />

View File

@@ -149,11 +149,12 @@ function updateAuthenticatedUI(data) {
if (data.username) {
localStorage.setItem("username", data.username);
}
/*
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
}
*/
const headerButtons = document.querySelector(".header-buttons");
const firstButton = headerButtons.firstElementChild;
@@ -227,6 +228,10 @@ function checkAuthentication(showLoginToast = true) {
}
window.setupMode = false;
if (data.authenticated) {
localStorage.setItem("folderOnly", data.folderOnly );
localStorage.setItem("readOnly", data.readOnly );
localStorage.setItem("disableUpload",data.disableUpload);
updateLoginOptionsUIFromStorage();
if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
}
@@ -249,6 +254,7 @@ function checkAuthentication(showLoginToast = true) {
function submitLogin(data) {
setLastLoginData(data);
window.__lastLoginData = data;
sendRequest("api/auth/auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success || response.status === "ok") {
@@ -263,7 +269,7 @@ function submitLogin(data) {
}
})
.catch(() => {
// if fetching permissions fails.
// ignore permissionfetch errors
})
.finally(() => {
window.location.reload();
@@ -272,7 +278,7 @@ function submitLogin(data) {
openTOTPLoginModal();
} else if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
const loginButton = document.getElementById("authForm").querySelector("button[type='submit']");
const loginButton = document.querySelector("#authForm button[type='submit']");
if (loginButton) {
loginButton.disabled = true;
setTimeout(() => {
@@ -284,10 +290,18 @@ function submitLogin(data) {
showToast("Login failed: " + (response.error || "Unknown error"));
}
})
.catch(() => {
showToast("Login failed: Unknown error");
.catch(err => {
// err may be an Error object or a string
let msg = "Unknown error";
if (err && typeof err === "object") {
msg = err.error || err.message || msg;
} else if (typeof err === "string") {
msg = err;
}
showToast(`Login failed: ${msg}`);
});
}
window.submitLogin = submitLogin;
/* ----------------- Other Helpers ----------------- */

View File

@@ -1,31 +1,31 @@
// public/js/networkUtils.js
export function sendRequest(url, method = "GET", data = null, customHeaders = {}) {
const options = {
method,
credentials: 'include',
headers: {}
headers: { ...customHeaders }
};
// Merge custom headers
Object.assign(options.headers, customHeaders);
// If data is provided and is not FormData, assume JSON.
if (data && !(data instanceof FormData)) {
if (!options.headers["Content-Type"]) {
options.headers["Content-Type"] = "application/json";
}
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
options.body = data;
}
return fetch(url, options)
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error ${response.status}: ${text}`);
});
}
const clonedResponse = response.clone();
return response.json().catch(() => clonedResponse.text());
.then(async res => {
const text = await res.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
if (!res.ok) {
// Reject with the parsed JSON (or raw text) so .catch(error) gets it
throw payload;
}
return payload;
});
}

View File

@@ -58,49 +58,73 @@ class AuthController
*
* @return void Redirects on success or outputs JSON error.
*/
// in src/controllers/AuthController.php
public function auth(): void
{
// Global exception handler.
header('Content-Type: application/json');
set_exception_handler(function ($e) {
error_log("Unhandled exception: " . $e->getMessage());
http_response_code(500);
echo json_encode(["error" => "Internal Server Error"]);
echo json_encode(['error' => 'Internal Server Error']);
exit();
});
header('Content-Type: application/json');
// Decode any JSON payload
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim($data['username'] ?? '');
$password = trim($data['password'] ?? '');
$totpCode = trim($data['totp_code'] ?? '');
$rememberMe = !empty($data['remember_me']);
// If OIDC parameters are present, initiate OIDC flow.
//
// 1) TOTPonly step: user already passed credentials and we asked for TOTP,
// now they POST just totp_code.
//
if ($totpCode && isset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'])) {
$username = $_SESSION['pending_login_user'];
$secret = $_SESSION['pending_login_secret'];
$tfa = new TwoFactorAuth(new GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, Algorithm::Sha1);
if (! $tfa->verifyCode($secret, $totpCode)) {
echo json_encode(['error' => 'Invalid TOTP code']);
exit();
}
// clear the pending markers
unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
// now finish login
$this->finalizeLogin($username, $rememberMe);
}
//
// 2) OIDC flow
//
$oidcAction = $_GET['oidc'] ?? null;
if (! $oidcAction && isset($_GET['code'])) {
$oidcAction = 'callback';
}
if ($oidcAction) {
// new: delegate to AdminModel
$cfg = AdminModel::getConfig();
// Optional: log to confirm you loaded the right values
error_log("Loaded OIDC config: " . print_r($cfg['oidc'], true));
$oidc_provider_url = $cfg['oidc']['providerUrl'];
$oidc_client_id = $cfg['oidc']['clientId'];
$oidc_client_secret = $cfg['oidc']['clientSecret'];
$oidc_redirect_uri = $cfg['oidc']['redirectUri'];
$oidc = new OpenIDConnectClient($oidc_provider_url, $oidc_client_id, $oidc_client_secret);
$oidc->setRedirectURL($oidc_redirect_uri);
$oidc = new OpenIDConnectClient(
$cfg['oidc']['providerUrl'],
$cfg['oidc']['clientId'],
$cfg['oidc']['clientSecret']
);
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
if ($oidcAction === 'callback') {
try {
$oidc->authenticate();
$username = $oidc->requestUserInfo('preferred_username');
// Check for TOTP secret.
// check if this user has a TOTP secret
$totp_secret = null;
$usersFile = USERS_DIR . USERS_FILE;
if (file_exists($usersFile)) {
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(":", trim($line));
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
$totp_secret = decryptData($parts[3], $encryptionKey);
$parts = explode(':', trim($line));
if (count($parts) >= 4 && $parts[0] === $username && $parts[3] !== '') {
$totp_secret = decryptData($parts[3], $GLOBALS['encryptionKey']);
break;
}
}
@@ -108,159 +132,159 @@ class AuthController
if ($totp_secret) {
$_SESSION['pending_login_user'] = $username;
$_SESSION['pending_login_secret'] = $totp_secret;
header("Location: /index.html?totp_required=1");
header('Location: /index.html?totp_required=1');
exit();
}
// Finalize login (no TOTP)
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1");
$_SESSION["folderOnly"] = loadUserPermissions($username);
header("Location: /index.html");
exit();
} catch (Exception $e) {
error_log("OIDC authentication error: " . $e->getMessage());
// no TOTP → finish immediately
$this->finishBrowserLogin($username);
} catch (\Exception $e) {
error_log("OIDC auth error: " . $e->getMessage());
http_response_code(401);
echo json_encode(["error" => "Authentication failed."]);
echo json_encode(['error' => 'Authentication failed.']);
exit();
}
} else {
// Initiate OIDC authentication.
// initial OIDC redirect
try {
$oidc->authenticate();
exit();
} catch (Exception $e) {
} catch (\Exception $e) {
error_log("OIDC initiation error: " . $e->getMessage());
http_response_code(401);
echo json_encode(["error" => "Authentication initiation failed."]);
echo json_encode(['error' => 'Authentication initiation failed.']);
exit();
}
}
}
// Fallback: Form-based Authentication.
$data = json_decode(file_get_contents("php://input"), true);
$username = trim($data["username"] ?? "");
$password = trim($data["password"] ?? "");
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
//
// 3) Formbased / AJAX login
//
if (! $username || ! $password) {
http_response_code(400);
echo json_encode(["error" => "Username and password are required"]);
echo json_encode(['error' => 'Username and password are required']);
exit();
}
if (! preg_match(REGEX_USER, $username)) {
http_response_code(400);
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
echo json_encode(['error' => 'Invalid username format']);
exit();
}
// ratelimit
$ip = $_SERVER['REMOTE_ADDR'];
$currentTime = time();
$attemptsFile = USERS_DIR . 'failed_logins.json';
$failedAttempts = AuthModel::loadFailedAttempts($attemptsFile);
$maxAttempts = 5;
$lockoutTime = 30 * 60; // 30 minutes
if (isset($failedAttempts[$ip])) {
$attemptData = $failedAttempts[$ip];
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
$failed = AuthModel::loadFailedAttempts($attemptsFile);
if (
isset($failed[$ip]) &&
$failed[$ip]['count'] >= 5 &&
time() - $failed[$ip]['last_attempt'] < 30 * 60
) {
http_response_code(429);
echo json_encode(["error" => "Too many failed login attempts. Please try again later."]);
echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']);
exit();
}
}
$user = AuthModel::authenticate($username, $password);
if ($user !== false) {
// Handle TOTP if required.
if ($user === false) {
// record failure
$failed[$ip] = [
'count' => ($failed[$ip]['count'] ?? 0) + 1,
'last_attempt' => time()
];
AuthModel::saveFailedAttempts($attemptsFile, $failed);
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
exit();
}
// if this account has TOTP, ask for it
if (! empty($user['totp_secret'])) {
if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) {
$_SESSION['pending_login_user'] = $username;
$_SESSION['pending_login_secret'] = $user['totp_secret'];
echo json_encode([
"totp_required" => true,
"message" => "TOTP code required"
]);
exit();
} else {
$tfa = new \RobThree\Auth\TwoFactorAuth(
new GoogleChartsQrCodeProvider(),
'FileRise',
6,
30,
Algorithm::Sha1
);
$providedCode = trim($data['totp_code']);
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
echo json_encode(["error" => "Invalid TOTP code"]);
echo json_encode(['totp_required' => true]);
exit();
}
// otherwise clear ratelimit & finish
if (isset($failed[$ip])) {
unset($failed[$ip]);
AuthModel::saveFailedAttempts($attemptsFile, $failed);
}
$this->finalizeLogin($username, $rememberMe);
}
// Clear failed attempts.
if (isset($failedAttempts[$ip])) {
unset($failedAttempts[$ip]);
AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts);
}
/**
* Finalize an AJAXstyle login (form/basic/TOTP) by
* issuing the session, rememberme cookie, and JSON payload.
*/
protected function finalizeLogin(string $username, bool $rememberMe): void
{
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($user['role'] === "1");
$_SESSION["folderOnly"] = loadUserPermissions($username);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1');
// Handle "remember me"
$perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
// rememberme
if ($rememberMe) {
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
$tokenPersistent = bin2hex(random_bytes(32));
$expiry = time() + (30 * 24 * 60 * 60);
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
$persistentTokens = json_decode($decryptedContent, true);
if (!is_array($persistentTokens)) {
$persistentTokens = [];
$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) ?: [];
}
}
$persistentTokens[$tokenPersistent] = [
"username" => $username,
"expiry" => $expiry,
"isAdmin" => ($_SESSION["isAdmin"] === true)
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => $_SESSION['isAdmin']
];
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']);
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('remember_me_token', $tokenPersistent, $expiry, '/', '', $secure, true);
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
}
echo json_encode([
"status" => "ok",
"success" => "Login successful",
"isAdmin" => $_SESSION["isAdmin"],
"folderOnly" => $_SESSION["folderOnly"],
"username" => $_SESSION["username"]
'status' => 'ok',
'success' => 'Login successful',
'isAdmin' => $_SESSION['isAdmin'],
'folderOnly' => $_SESSION['folderOnly'],
'readOnly' => $_SESSION['readOnly'],
'disableUpload' => $_SESSION['disableUpload'],
'username' => $username
]);
} else {
// Record failed login attempt.
if (isset($failedAttempts[$ip])) {
$failedAttempts[$ip]['count']++;
$failedAttempts[$ip]['last_attempt'] = $currentTime;
} else {
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
}
AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts);
$failedLogFile = USERS_DIR . 'failed_login.log';
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
http_response_code(401);
echo json_encode(["error" => "Invalid credentials"]);
exit();
}
/**
* A version of finalizeLogin() that ends in a browser redirect
* (used for OIDC nonAJAX flows).
*/
protected function finishBrowserLogin(string $username): void
{
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1');
$perms = loadUserPermissions($username);
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
header('Location: /index.html');
exit();
}
/**
@@ -296,51 +320,45 @@ class AuthController
*
* @return void Outputs a JSON response with authentication details.
*/
public function checkAuth(): void
{
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
// If the users file does not exist or is empty, signal setup mode.
// setup mode?
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
error_log("checkAuth: users file not found or empty; entering setup mode.");
echo json_encode(["setup" => true]);
exit;
error_log("checkAuth: setup mode");
echo json_encode(['setup' => true]);
exit();
}
if (empty($_SESSION['authenticated'])) {
echo json_encode(['authenticated' => false]);
exit();
}
// If the session is not authenticated, output false.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["authenticated" => false]);
exit;
}
// Retrieve the username from the session.
$username = $_SESSION['username'] ?? '';
// Determine TOTP enabled by checking the users file.
$totp_enabled = false;
if ($username) {
// TOTP enabled?
$totp = false;
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$parts = explode(':', trim($line));
if ($parts[0] === $username && isset($parts[3]) && trim($parts[3]) !== "") {
$totp_enabled = true;
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
$totp = true;
break;
}
}
}
// Determine admin status using AuthModel::getUserRole()
$userRole = AuthModel::getUserRole($username);
$isAdmin = ((int)$userRole === 1);
$response = [
"authenticated" => true,
"isAdmin" => $isAdmin,
"totp_enabled" => $totp_enabled,
"username" => $username,
"folderOnly" => $_SESSION["folderOnly"] ?? false
$isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1);
$resp = [
'authenticated' => true,
'isAdmin' => $isAdmin,
'totp_enabled' => $totp,
'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'] ?? false,
'readOnly' => $_SESSION['readOnly'] ?? false,
'disableUpload' => $_SESSION['disableUpload'] ?? false
];
echo json_encode($response);
exit;
echo json_encode($resp);
exit();
}
/**
@@ -441,7 +459,11 @@ class AuthController
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1");
$_SESSION["folderOnly"] = AuthModel::loadFolderPermission($username);
// load _all_ the permissions
$userPerms = loadUserPermissions($username);
$_SESSION["folderOnly"] = $userPerms["folderOnly"] ?? false;
$_SESSION["readOnly"] = $userPerms["readOnly"] ?? false;
$_SESSION["disableUpload"] = $userPerms["disableUpload"] ?? false;
header("Location: /index.html");
exit;

View File

@@ -1301,7 +1301,18 @@ class FileController {
// Delegate deletion to the model.
$result = FileModel::deleteTrashFiles($filesToDelete);
echo json_encode($result);
// Build a humanfriendly success or error message
if (!empty($result['deleted'])) {
$count = count($result['deleted']);
$msg = "Trash item" . ($count===1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']);
echo json_encode(["success" => $msg]);
} elseif (!empty($result['error'])) {
echo json_encode(["error" => $result['error']]);
} else {
echo json_encode(["success" => "No items to delete."]);
}
exit;
}
/**