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 = `
`;
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