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';
// ── Cookie-based auth (no PHP sessions — avoids server caching/permission issues) ──
define('AUTH_COOKIE', 'parker_auth');
define('AUTH_SECRET', hash('sha256', ADMIN_PASS . ADMIN_SESSION_KEY)); // not reversible
function _authToken(): string {
$t = bin2hex(random_bytes(32));
$e = time() + 86400; // 24h
$s = hash_hmac('sha256', "$t|$e", AUTH_SECRET);
return "$t.$e.$s";
// ── URL-token auth (no cookies — works in all browsers) ───────────────────────
function _createToken(): string {
$token = bin2hex(random_bytes(32));
db()->prepare("INSERT INTO admin_tokens (token, expires_at) VALUES (?, ?)")
->execute([$token, date('Y-m-d H:i:s', time() + 86400)]);
db()->exec("DELETE FROM admin_tokens WHERE expires_at < NOW()");
return $token;
}
function _verifyAuth(): bool {
$c = $_COOKIE[AUTH_COOKIE] ?? '';
$p = explode('.', $c, 3);
if (count($p) !== 3) return false;
[$t, $e, $s] = $p;
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']);
function _verifyToken(string $token): bool {
if (!preg_match('/^[a-f0-9]{64}$/', $token)) return false;
$stmt = db()->prepare("SELECT token FROM admin_tokens WHERE token=? AND expires_at > NOW()");
$stmt->execute([$token]);
return (bool)$stmt->fetch();
}
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (($_SERVER['HTTP_ACCEPT'] ?? '') === 'application/json');
// ── Auth ──────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') {
if ($_POST['username'] === ADMIN_USER && password_verify($_POST['password'] ?? '', ADMIN_PASS)) {
_setAuth();
if (($_POST['username'] ?? '') === ADMIN_USER && password_verify($_POST['password'] ?? '', ADMIN_PASS)) {
$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;
}
if (($_GET['action'] ?? '') === 'logout') {
_clearAuth(); header('Location: /admin/'); exit;
}
$authed = _verifyAuth();
$authed = $rawToken !== '' && _verifyToken($rawToken);
$token = $authed ? $rawToken : '';
// ── AJAX handlers ─────────────────────────────────────────────────────────────
if ($isAjax && !$authed) {
http_response_code(401);
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;
}
if ($isAjax) {
@@ -280,6 +268,9 @@ button:hover{background:#ea580c}
<div class="box">
<h1>Parker Admin</h1>
<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">
<input type="hidden" name="action" value="login">
<label>Username</label>
@@ -427,7 +418,7 @@ textarea.notes-ta:focus{border-color:#f97316}
<body>
<header>
<h1>Parker County Slingshot — Admin</h1>
<a href="?action=logout">Sign Out</a>
<a href="?action=logout&_t=<?= htmlspecialchars($token) ?>">Sign Out</a>
</header>
<div class="main">
@@ -796,6 +787,8 @@ textarea.notes-ta:focus{border-color:#f97316}
</div><!-- /.main -->
<script>
const ADMIN_TOKEN = '<?= htmlspecialchars($token) ?>';
// ── Status filter (client-side — no page navigation) ─────────────────────────
function filterBookings(status, btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
@@ -822,7 +815,7 @@ function updateStatus(sel) {
fetch('/admin/', {
method:'POST',
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'); });
}
@@ -832,7 +825,7 @@ function toggleReq(id, field, btn) {
fetch('/admin/', {
method:'POST',
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(d=>{
@@ -886,7 +879,7 @@ function saveNotes(id) {
fetch('/admin/', {
method:'POST',
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=>{
btn.textContent = d.ok ? 'Saved ✓' : 'Error';
setTimeout(()=>btn.textContent='Save Notes', 1800);
@@ -906,7 +899,7 @@ function sendReminder(id) {
fetch('/admin/', {
method:'POST',
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=>{
btn.disabled = false;
if (d.ok) {
@@ -946,7 +939,7 @@ function squareAction(id, action, btn) {
fetch('/admin/', {
method:'POST',
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=>{
if (d.ok) {
btn.textContent = done;
@@ -997,7 +990,7 @@ function blockDate(e) {
fetch('/admin/', {
method:'POST',
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); });
}
function unblockDate(id) {
@@ -1005,7 +998,7 @@ function unblockDate(id) {
fetch('/admin/', {
method:'POST',
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(); });
}
</script>