diff --git a/auth.php b/auth.php index 231dcef..fa52502 100644 --- a/auth.php +++ b/auth.php @@ -186,14 +186,17 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) { $user = authenticate($username, $password); if ($user !== false) { if (!empty($user['totp_secret'])) { - // If TOTP code is missing or malformed, indicate that TOTP is required. - if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) { - echo json_encode([ - "totp_required" => true, - "message" => "TOTP code required" - ]); - exit(); - } else { + // If TOTP code is missing or malformed, indicate that TOTP is required. + if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) { + // ← STORE pending user & secret so recovery can see it + $_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('FileRise'); $providedCode = trim($data['totp_code']); if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) { diff --git a/js/authModals.js b/js/authModals.js index 8cd7ca0..b28c408 100644 --- a/js/authModals.js +++ b/js/authModals.js @@ -31,68 +31,110 @@ export function openTOTPLoginModal() { totpLoginModal.innerHTML = `
× -

Enter TOTP Code

- +
+

Enter TOTP Code

+ +
+ Use Recovery Code instead +
`; document.body.appendChild(totpLoginModal); + // Close button document.getElementById("closeTOTPLoginModal").addEventListener("click", () => { totpLoginModal.style.display = "none"; }); + // Toggle between TOTP and Recovery + document.getElementById("toggleRecovery").addEventListener("click", e => { + e.preventDefault(); + document.getElementById("totpSection").style.display = + document.getElementById("recoverySection").style.display === "none" ? "none" : "block"; + document.getElementById("recoverySection").style.display = + document.getElementById("recoverySection").style.display === "none" ? "block" : "none"; + }); + + // Recovery submission + document.getElementById("submitRecovery").addEventListener("click", () => { + const recoveryCode = document.getElementById("recoveryInput").value.trim(); + if (!recoveryCode) { + showToast("Please enter your recovery code."); + return; + } + fetch("totp_recover.php", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": window.csrfToken + }, + body: JSON.stringify({ recovery_code: recoveryCode }) + }) + .then(res => res.json()) + .then(json => { + if (json.status === "ok") { + // recovery succeeded → finalize login + window.location.href = "index.html"; + } else { + showToast(json.message || "Recovery code verification failed"); + } + }) + .catch(() => { + showToast("Error verifying recovery code."); + }); + }); + + // TOTP submission const totpInput = document.getElementById("totpLoginInput"); totpInput.focus(); - totpInput.addEventListener("input", function () { const code = this.value.trim(); if (code.length === 6) { - if (lastLoginData) { - totpLoginModal.style.display = "none"; - lastLoginData.totp_code = code; - window.submitLogin(lastLoginData); - } else { - fetch("totp_verify.php", { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": window.csrfToken - }, - body: JSON.stringify({ totp_code: code }) - }) - .then(res => res.json()) - .then(json => { - if (json.status === "ok") { - window.location.href = "index.html"; - } else { - showToast(json.message || "TOTP verification failed"); - this.value = ""; - totpLoginModal.style.display = "flex"; - totpInput.focus(); - } - }) - .catch(() => { - showToast("TOTP verification failed"); + fetch("totp_verify.php", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": window.csrfToken + }, + body: JSON.stringify({ totp_code: code }) + }) + .then(res => res.json()) + .then(json => { + if (json.status === "ok") { + window.location.href = "index.html"; + } else { + showToast(json.message || "TOTP verification failed"); this.value = ""; totpLoginModal.style.display = "flex"; totpInput.focus(); - }); - } + } + }) + .catch(() => { + showToast("TOTP verification failed"); + this.value = ""; + totpLoginModal.style.display = "flex"; + totpInput.focus(); + }); } }); } else { + // Re-open existing modal totpLoginModal.style.display = "flex"; - const modalContent = totpLoginModal.firstElementChild; - modalContent.style.background = modalBg; - modalContent.style.color = textColor; const totpInput = document.getElementById("totpLoginInput"); - if (totpInput) { - totpInput.value = ""; - totpInput.focus(); - } + totpInput.value = ""; + totpInput.style.display = "block"; + totpInput.focus(); + document.getElementById("recoverySection").style.display = "none"; } } @@ -185,6 +227,36 @@ export function openUserPanel() { userPanelModal.style.display = "flex"; } +function showRecoveryCodeModal(recoveryCode) { + const recoveryModal = document.createElement("div"); + recoveryModal.id = "recoveryModal"; + recoveryModal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0,0,0,0.3); + display: flex; + justify-content: center; + align-items: center; + z-index: 3200; + `; + recoveryModal.innerHTML = ` +
+

Your Recovery Code

+

Please save this code securely. It will not be shown again and can only be used once.

+ ${recoveryCode} + +
+ `; + document.body.appendChild(recoveryModal); + + document.getElementById("closeRecoveryModal").addEventListener("click", () => { + recoveryModal.remove(); + }); +} + export function openTOTPModal() { let totpModal = document.getElementById("totpModal"); const isDarkMode = document.body.classList.contains("dark-mode"); @@ -251,6 +323,25 @@ export function openTOTPModal() { .then(result => { if (result.status === 'ok') { showToast("TOTP successfully enabled."); + // After successful TOTP verification, fetch the recovery code + fetch("totp_saveCode.php", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": window.csrfToken + } + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok' && data.recoveryCode) { + // Show the recovery code in a secure modal + showRecoveryCodeModal(data.recoveryCode); + } else { + showToast("Error generating recovery code: " + (data.message || "Unknown error.")); + } + }) + .catch(() => { showToast("Error generating recovery code."); }); closeTOTPModal(false); } else { showToast("TOTP verification failed: " + (result.message || "Invalid code.")); diff --git a/totp_recover.php b/totp_recover.php new file mode 100644 index 0000000..f03337e --- /dev/null +++ b/totp_recover.php @@ -0,0 +1,115 @@ +'error','message'=>'Method not allowed'])); +} + +// ——— 2) CSRF check ——— +if (empty($_SERVER['HTTP_X_CSRF_TOKEN']) + || $_SERVER['HTTP_X_CSRF_TOKEN'] !== ($_SESSION['csrf_token'] ?? '')) { + http_response_code(403); + error_log("Invalid CSRF token on recovery for IP {$_SERVER['REMOTE_ADDR']}"); + exit(json_encode(['status'=>'error','message'=>'Invalid CSRF token'])); +} + +// ——— 3) Identify user to recover ——— +$userId = $_SESSION['username'] + ?? $_SESSION['pending_login_user'] + ?? null; + +if (!$userId) { + http_response_code(401); + error_log("Unauthorized recovery attempt from IP {$_SERVER['REMOTE_ADDR']}"); + exit(json_encode(['status'=>'error','message'=>'Unauthorized'])); +} + +// ——— Validate userId format ——— +if (!preg_match('/^[A-Za-z0-9_\-]+$/', $userId)) { + http_response_code(400); + error_log("Invalid userId format: {$userId}"); + exit(json_encode(['status'=>'error','message'=>'Invalid user identifier'])); +} + +// ——— Rate‑limit recovery attempts ——— +$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json'; +$attempts = is_file($attemptsFile) + ? json_decode(file_get_contents($attemptsFile), true) + : []; +$key = $_SERVER['REMOTE_ADDR'] . '|' . $userId; +$now = time(); +// Prune >15 min old +if (isset($attempts[$key])) { + $attempts[$key] = array_filter( + $attempts[$key], + fn($ts) => $ts > $now - 900 + ); +} +if (count($attempts[$key] ?? []) >= 5) { + http_response_code(429); + exit(json_encode(['status'=>'error','message'=>'Too many attempts. Try again later.'])); +} + +// ——— 4) Load user metadata file ——— +$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; +if (!file_exists($userFile)) { + http_response_code(404); + error_log("User file not found for recovery: {$userFile}"); + exit(json_encode(['status'=>'error','message'=>'User not found'])); +} + +// ——— 5) Read & lock file ——— +$fp = fopen($userFile, 'c+'); +if (!$fp || !flock($fp, LOCK_EX)) { + http_response_code(500); + error_log("Failed to lock user file: {$userFile}"); + exit(json_encode(['status'=>'error','message'=>'Server error'])); +} +$data = json_decode(stream_get_contents($fp), true) ?: []; + +// ——— 6) Verify recovery code ——— +$input = json_decode(file_get_contents('php://input'), true)['recovery_code'] ?? ''; +if (!$input) { + flock($fp, LOCK_UN); + fclose($fp); + http_response_code(400); + exit(json_encode(['status'=>'error','message'=>'Recovery code required'])); +} + +$hash = $data['totp_recovery_code'] ?? null; +if (!$hash || !password_verify($input, $hash)) { + // record failed attempt + $attempts[$key][] = $now; + file_put_contents($attemptsFile, json_encode($attempts), LOCK_EX); + + flock($fp, LOCK_UN); + fclose($fp); + error_log("Invalid recovery code for user {$userId} from IP {$_SERVER['REMOTE_ADDR']}"); + exit(json_encode(['status'=>'error','message'=>'Invalid recovery code'])); +} + +// ——— 7) Invalidate code & save ——— +$data['totp_recovery_code'] = null; +rewind($fp); +ftruncate($fp, 0); +fwrite($fp, json_encode($data)); // no pretty-print in prod +fflush($fp); +flock($fp, LOCK_UN); +fclose($fp); + +// ——— 8) Finalize login ——— +session_regenerate_id(true); +$_SESSION['authenticated'] = true; +$_SESSION['username'] = $userId; +unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); + +// ——— 9) Success ——— +echo json_encode(['status'=>'ok']); +exit; \ No newline at end of file diff --git a/totp_saveCode.php b/totp_saveCode.php new file mode 100644 index 0000000..3050977 --- /dev/null +++ b/totp_saveCode.php @@ -0,0 +1,85 @@ +'error','message'=>'Method not allowed'])); +} + +// 2) CSRF check +if (empty($_SERVER['HTTP_X_CSRF_TOKEN']) + || $_SERVER['HTTP_X_CSRF_TOKEN'] !== ($_SESSION['csrf_token'] ?? '')) { + http_response_code(403); + error_log("totp_saveCode: invalid CSRF token from IP {$_SERVER['REMOTE_ADDR']}"); + exit(json_encode(['status'=>'error','message'=>'Invalid CSRF token'])); +} + +// 3) Must be logged in +if (empty($_SESSION['username'])) { + http_response_code(401); + error_log("totp_saveCode: unauthorized attempt from IP {$_SERVER['REMOTE_ADDR']}"); + exit(json_encode(['status'=>'error','message'=>'Unauthorized'])); +} + +// 4) Validate username format +$userId = $_SESSION['username']; +if (!preg_match('/^[A-Za-z0-9_\-]+$/', $userId)) { + http_response_code(400); + error_log("totp_saveCode: invalid username format: {$userId}"); + exit(json_encode(['status'=>'error','message'=>'Invalid user identifier'])); +} + +// 5) Ensure user file exists (create if missing) +$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; +if (!file_exists($userFile)) { + $defaultData = []; + if (file_put_contents($userFile, json_encode($defaultData)) === false) { + http_response_code(500); + error_log("totp_saveCode: failed to create user file: {$userFile}"); + exit(json_encode(['status'=>'error','message'=>'Server error'])); + } +} + +// 6) Generate secure recovery code +function generateRecoveryCode($length = 12) { + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $max = strlen($chars) - 1; + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= $chars[random_int(0, $max)]; + } + return $code; +} +$recoveryCode = generateRecoveryCode(); +$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT); + +// 7) Read, lock, update user file +$fp = fopen($userFile, 'c+'); +if (!$fp || !flock($fp, LOCK_EX)) { + http_response_code(500); + error_log("totp_saveCode: failed to lock user file: {$userFile}"); + exit(json_encode(['status'=>'error','message'=>'Server error'])); +} + +$data = json_decode(stream_get_contents($fp), true) ?: []; +$data['totp_recovery_code'] = $recoveryHash; + +rewind($fp); +ftruncate($fp, 0); +fwrite($fp, json_encode($data)); // no pretty-print in prod +fflush($fp); +flock($fp, LOCK_UN); +fclose($fp); + +// 8) Return one-time recovery code +echo json_encode([ + 'status' => 'ok', + 'recoveryCode' => $recoveryCode +]); +exit; \ No newline at end of file