totp one time recovery code added

This commit is contained in:
Ryan
2025-04-06 14:35:45 -04:00
committed by GitHub
parent b4445fc4d8
commit 70163d22f0
4 changed files with 343 additions and 49 deletions

View File

@@ -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)) {

View File

@@ -31,68 +31,110 @@ export function openTOTPLoginModal() {
totpLoginModal.innerHTML = `
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
<span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span>
<h3>Enter TOTP Code</h3>
<input type="text" id="totpLoginInput" maxlength="6"
style="font-size:24px; text-align:center; width:100%; padding:10px;"
placeholder="6-digit code" />
<div id="totpSection">
<h3>Enter TOTP Code</h3>
<input type="text" id="totpLoginInput" maxlength="6"
style="font-size:24px; text-align:center; width:100%; padding:10px;"
placeholder="6-digit code" />
</div>
<a href="#" id="toggleRecovery" style="display:block; margin-top:10px; font-size:14px;">Use Recovery Code instead</a>
<div id="recoverySection" style="display:none; margin-top:10px;">
<h3>Enter Recovery Code</h3>
<input type="text" id="recoveryInput"
style="font-size:24px; text-align:center; width:100%; padding:10px;"
placeholder="Recovery code" />
<button type="button" id="submitRecovery" class="btn btn-secondary" style="margin-top:10px;">Submit Recovery Code</button>
</div>
</div>
`;
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 = `
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
<h3>Your Recovery Code</h3>
<p>Please save this code securely. It will not be shown again and can only be used once.</p>
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
<button type="button" id="closeRecoveryModal" class="btn btn-primary">OK</button>
</div>
`;
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."));

115
totp_recover.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
// totp_recover.php
require_once 'config.php';
header('Content-Type: application/json');
// ——— 1) Only POST ———
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
error_log("Recovery attempt with invalid method: {$_SERVER['REQUEST_METHOD']}");
exit(json_encode(['status'=>'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']));
}
// ——— Ratelimit 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 >15min 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;

85
totp_saveCode.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
// totp_saveCode.php
require_once 'config.php';
header('Content-Type: application/json');
// 1) Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
error_log("totp_saveCode: invalid method {$_SERVER['REQUEST_METHOD']}");
exit(json_encode(['status'=>'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;