Replace cookie auth with URL token auth in admin portal

Cookies failed consistently in real browsers despite working in curl.
Replaced with DB-stored token passed as ?_t=TOKEN in URL:
- Login generates 64-char hex token, stores in admin_tokens table
- Redirect to /admin/?_t=TOKEN after successful login
- Every request validated via DB lookup (no cookies needed)
- All 7 AJAX calls include &_t=TOKEN in POST body
- Logout deletes token from DB
- Requires admin_tokens table (created in DB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 18:59:50 +00:00
parent 0b36064cc6
commit 654aecc2dd
+38 -45
View File
@@ -6,58 +6,46 @@ header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
require_once dirname(__DIR__) . '/db.php'; require_once dirname(__DIR__) . '/db.php';
// ── Cookie-based auth (no PHP sessions — avoids server caching/permission issues) ── // ── URL-token auth (no cookies — works in all browsers) ───────────────────────
define('AUTH_COOKIE', 'parker_auth'); function _createToken(): string {
define('AUTH_SECRET', hash('sha256', ADMIN_PASS . ADMIN_SESSION_KEY)); // not reversible $token = bin2hex(random_bytes(32));
db()->prepare("INSERT INTO admin_tokens (token, expires_at) VALUES (?, ?)")
function _authToken(): string { ->execute([$token, date('Y-m-d H:i:s', time() + 86400)]);
$t = bin2hex(random_bytes(32)); db()->exec("DELETE FROM admin_tokens WHERE expires_at < NOW()");
$e = time() + 86400; // 24h return $token;
$s = hash_hmac('sha256', "$t|$e", AUTH_SECRET);
return "$t.$e.$s";
} }
function _verifyAuth(): bool { function _verifyToken(string $token): bool {
$c = $_COOKIE[AUTH_COOKIE] ?? ''; if (!preg_match('/^[a-f0-9]{64}$/', $token)) return false;
$p = explode('.', $c, 3); $stmt = db()->prepare("SELECT token FROM admin_tokens WHERE token=? AND expires_at > NOW()");
if (count($p) !== 3) return false; $stmt->execute([$token]);
[$t, $e, $s] = $p; return (bool)$stmt->fetch();
if ((int)$e < time()) return false;
return hash_equals(hash_hmac('sha256', "$t|$e", AUTH_SECRET), $s);
}
function _setAuth(): void {
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie(AUTH_COOKIE, _authToken(), [
'expires' => time() + 86400,
'path' => '/admin/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
function _clearAuth(): void {
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie(AUTH_COOKIE, '', ['expires' => time()-3600, 'path' => '/admin/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax']);
} }
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (($_SERVER['HTTP_ACCEPT'] ?? '') === 'application/json'); $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (($_SERVER['HTTP_ACCEPT'] ?? '') === 'application/json');
// ── Auth ────────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') {
if ($_POST['username'] === ADMIN_USER && password_verify($_POST['password'] ?? '', ADMIN_PASS)) { if (($_POST['username'] ?? '') === ADMIN_USER && password_verify($_POST['password'] ?? '', ADMIN_PASS)) {
_setAuth(); $t = _createToken();
header('Location: /admin/?_t=' . $t);
} else {
header('Location: /admin/?err=1');
} }
exit;
}
$rawToken = preg_replace('/[^a-f0-9]/', '', $_GET['_t'] ?? $_POST['_t'] ?? '');
if (($_GET['action'] ?? '') === 'logout') {
if ($rawToken) db()->prepare("DELETE FROM admin_tokens WHERE token=?")->execute([$rawToken]);
header('Location: /admin/'); exit; header('Location: /admin/'); exit;
} }
if (($_GET['action'] ?? '') === 'logout') { $authed = $rawToken !== '' && _verifyToken($rawToken);
_clearAuth(); header('Location: /admin/'); exit; $token = $authed ? $rawToken : '';
}
$authed = _verifyAuth();
// ── AJAX handlers ───────────────────────────────────────────────────────────── // ── AJAX handlers ─────────────────────────────────────────────────────────────
if ($isAjax && !$authed) { if ($isAjax && !$authed) {
http_response_code(401); http_response_code(401);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['error'=>'Session expired. Please reload and log in again.']); echo json_encode(['error'=>'Session expired. Please log in again.']);
exit; exit;
} }
if ($isAjax) { if ($isAjax) {
@@ -280,6 +268,9 @@ button:hover{background:#ea580c}
<div class="box"> <div class="box">
<h1>Parker Admin</h1> <h1>Parker Admin</h1>
<p>Slingshot Rentals Management</p> <p>Slingshot Rentals Management</p>
<?php if (!empty($_GET['err'])): ?>
<p style="color:#f87171;margin-bottom:1rem">Invalid username or password.</p>
<?php endif; ?>
<form method="POST"> <form method="POST">
<input type="hidden" name="action" value="login"> <input type="hidden" name="action" value="login">
<label>Username</label> <label>Username</label>
@@ -427,7 +418,7 @@ textarea.notes-ta:focus{border-color:#f97316}
<body> <body>
<header> <header>
<h1>Parker County Slingshot — Admin</h1> <h1>Parker County Slingshot — Admin</h1>
<a href="?action=logout">Sign Out</a> <a href="?action=logout&_t=<?= htmlspecialchars($token) ?>">Sign Out</a>
</header> </header>
<div class="main"> <div class="main">
@@ -796,6 +787,8 @@ textarea.notes-ta:focus{border-color:#f97316}
</div><!-- /.main --> </div><!-- /.main -->
<script> <script>
const ADMIN_TOKEN = '<?= htmlspecialchars($token) ?>';
// ── Status filter (client-side — no page navigation) ───────────────────────── // ── Status filter (client-side — no page navigation) ─────────────────────────
function filterBookings(status, btn) { function filterBookings(status, btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
@@ -822,7 +815,7 @@ function updateStatus(sel) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=update_status&id='+sel.dataset.id+'&status='+sel.value body:'action=update_status&id='+sel.dataset.id+'&status='+sel.value+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ if(!d.ok) alert('Error saving status'); }); }).then(r=>r.json()).then(d=>{ if(!d.ok) alert('Error saving status'); });
} }
@@ -832,7 +825,7 @@ function toggleReq(id, field, btn) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=toggle_requirement&id='+id+'&field='+field body:'action=toggle_requirement&id='+id+'&field='+field+'&_t='+ADMIN_TOKEN
}) })
.then(r=>r.json()) .then(r=>r.json())
.then(d=>{ .then(d=>{
@@ -886,7 +879,7 @@ function saveNotes(id) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=save_admin_notes&id='+id+'&notes='+encodeURIComponent(ta.value) body:'action=save_admin_notes&id='+id+'&notes='+encodeURIComponent(ta.value)+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ }).then(r=>r.json()).then(d=>{
btn.textContent = d.ok ? 'Saved ✓' : 'Error'; btn.textContent = d.ok ? 'Saved ✓' : 'Error';
setTimeout(()=>btn.textContent='Save Notes', 1800); setTimeout(()=>btn.textContent='Save Notes', 1800);
@@ -906,7 +899,7 @@ function sendReminder(id) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=send_reminder&id='+id+'&items='+checks.join(',') body:'action=send_reminder&id='+id+'&items='+checks.join(',')+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ }).then(r=>r.json()).then(d=>{
btn.disabled = false; btn.disabled = false;
if (d.ok) { if (d.ok) {
@@ -946,7 +939,7 @@ function squareAction(id, action, btn) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action='+action+'&id='+id body:'action='+action+'&id='+id+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ }).then(r=>r.json()).then(d=>{
if (d.ok) { if (d.ok) {
btn.textContent = done; btn.textContent = done;
@@ -997,7 +990,7 @@ function blockDate(e) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=block_date&date='+date+'&reason='+encodeURIComponent(reason) body:'action=block_date&date='+date+'&reason='+encodeURIComponent(reason)+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ if(d.ok) location.reload(); else alert(d.error); }); }).then(r=>r.json()).then(d=>{ if(d.ok) location.reload(); else alert(d.error); });
} }
function unblockDate(id) { function unblockDate(id) {
@@ -1005,7 +998,7 @@ function unblockDate(id) {
fetch('/admin/', { fetch('/admin/', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'}, headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body:'action=unblock_date&id='+id body:'action=unblock_date&id='+id+'&_t='+ADMIN_TOKEN
}).then(r=>r.json()).then(d=>{ if(d.ok) document.getElementById('blocked-'+id).remove(); }); }).then(r=>r.json()).then(d=>{ if(d.ok) document.getElementById('blocked-'+id).remove(); });
} }
</script> </script>