totp one time recovery code added
This commit is contained in:
19
auth.php
19
auth.php
@@ -186,14 +186,17 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
|
|||||||
$user = authenticate($username, $password);
|
$user = authenticate($username, $password);
|
||||||
if ($user !== false) {
|
if ($user !== false) {
|
||||||
if (!empty($user['totp_secret'])) {
|
if (!empty($user['totp_secret'])) {
|
||||||
// If TOTP code is missing or malformed, indicate that TOTP is required.
|
// If TOTP code is missing or malformed, indicate that TOTP is required.
|
||||||
if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) {
|
if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) {
|
||||||
echo json_encode([
|
// ← STORE pending user & secret so recovery can see it
|
||||||
"totp_required" => true,
|
$_SESSION['pending_login_user'] = $username;
|
||||||
"message" => "TOTP code required"
|
$_SESSION['pending_login_secret'] = $user['totp_secret'];
|
||||||
]);
|
echo json_encode([
|
||||||
exit();
|
"totp_required" => true,
|
||||||
} else {
|
"message" => "TOTP code required"
|
||||||
|
]);
|
||||||
|
exit();
|
||||||
|
} else {
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
|
||||||
$providedCode = trim($data['totp_code']);
|
$providedCode = trim($data['totp_code']);
|
||||||
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
|
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
|
||||||
|
|||||||
173
js/authModals.js
173
js/authModals.js
@@ -31,68 +31,110 @@ export function openTOTPLoginModal() {
|
|||||||
totpLoginModal.innerHTML = `
|
totpLoginModal.innerHTML = `
|
||||||
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
|
<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;">×</span>
|
<span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</span>
|
||||||
<h3>Enter TOTP Code</h3>
|
<div id="totpSection">
|
||||||
<input type="text" id="totpLoginInput" maxlength="6"
|
<h3>Enter TOTP Code</h3>
|
||||||
style="font-size:24px; text-align:center; width:100%; padding:10px;"
|
<input type="text" id="totpLoginInput" maxlength="6"
|
||||||
placeholder="6-digit code" />
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(totpLoginModal);
|
document.body.appendChild(totpLoginModal);
|
||||||
|
|
||||||
|
// Close button
|
||||||
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
||||||
totpLoginModal.style.display = "none";
|
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");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
|
|
||||||
totpInput.addEventListener("input", function () {
|
totpInput.addEventListener("input", function () {
|
||||||
const code = this.value.trim();
|
const code = this.value.trim();
|
||||||
if (code.length === 6) {
|
if (code.length === 6) {
|
||||||
if (lastLoginData) {
|
fetch("totp_verify.php", {
|
||||||
totpLoginModal.style.display = "none";
|
method: "POST",
|
||||||
lastLoginData.totp_code = code;
|
credentials: "include",
|
||||||
window.submitLogin(lastLoginData);
|
headers: {
|
||||||
} else {
|
"Content-Type": "application/json",
|
||||||
fetch("totp_verify.php", {
|
"X-CSRF-Token": window.csrfToken
|
||||||
method: "POST",
|
},
|
||||||
credentials: "include",
|
body: JSON.stringify({ totp_code: code })
|
||||||
headers: {
|
})
|
||||||
"Content-Type": "application/json",
|
.then(res => res.json())
|
||||||
"X-CSRF-Token": window.csrfToken
|
.then(json => {
|
||||||
},
|
if (json.status === "ok") {
|
||||||
body: JSON.stringify({ totp_code: code })
|
window.location.href = "index.html";
|
||||||
})
|
} else {
|
||||||
.then(res => res.json())
|
showToast(json.message || "TOTP verification failed");
|
||||||
.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 = "";
|
this.value = "";
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
});
|
}
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast("TOTP verification failed");
|
||||||
|
this.value = "";
|
||||||
|
totpLoginModal.style.display = "flex";
|
||||||
|
totpInput.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// Re-open existing modal
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
const modalContent = totpLoginModal.firstElementChild;
|
|
||||||
modalContent.style.background = modalBg;
|
|
||||||
modalContent.style.color = textColor;
|
|
||||||
const totpInput = document.getElementById("totpLoginInput");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
if (totpInput) {
|
totpInput.value = "";
|
||||||
totpInput.value = "";
|
totpInput.style.display = "block";
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
}
|
document.getElementById("recoverySection").style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +227,36 @@ export function openUserPanel() {
|
|||||||
userPanelModal.style.display = "flex";
|
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() {
|
export function openTOTPModal() {
|
||||||
let totpModal = document.getElementById("totpModal");
|
let totpModal = document.getElementById("totpModal");
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
@@ -251,6 +323,25 @@ export function openTOTPModal() {
|
|||||||
.then(result => {
|
.then(result => {
|
||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
showToast("TOTP successfully enabled.");
|
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);
|
closeTOTPModal(false);
|
||||||
} else {
|
} else {
|
||||||
showToast("TOTP verification failed: " + (result.message || "Invalid code."));
|
showToast("TOTP verification failed: " + (result.message || "Invalid code."));
|
||||||
|
|||||||
115
totp_recover.php
Normal file
115
totp_recover.php
Normal 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']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— 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;
|
||||||
85
totp_saveCode.php
Normal file
85
totp_saveCode.php
Normal 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;
|
||||||
Reference in New Issue
Block a user