Admin: log in once, keep a session cookie instead of the key in the URL
The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:
- POST /api/admin/login {key} sets bingo_admin=<random token>
(HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only
Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
This commit is contained in:
1 parent
b6f0791243
commit
9b421c9b47
8 files changed
+320
-66
No files matched your search
+66
-19
@@ -108,15 +108,16 @@
|
||||
<button class="btn primary" type="button" data-csv="weeks">CSV: недели</button>
|
||||
<button class="btn primary" type="button" data-csv="cells">CSV: клетки</button>
|
||||
<button class="btn primary" type="button" data-csv="pulse">CSV: опрос</button>
|
||||
<button class="btn" type="button" id="logout">Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel login" id="login" hidden>
|
||||
<h2>Нужен ключ</h2>
|
||||
<p class="note">Откройте секретную ссылку целиком (с частью после #) или вставьте ключ сюда. Ключ не уходит никуда, кроме запроса к этому сайту.</p>
|
||||
<h2>Вход для организаторов</h2>
|
||||
<p class="note">Вставьте ключ администратора или откройте ссылку для входа. После входа ключ убирается из адреса, а браузер помнит вход ограниченное время.</p>
|
||||
<form id="login-form">
|
||||
<input id="key-input" type="password" autocomplete="off" placeholder="Ключ администратора" aria-label="Ключ администратора">
|
||||
<button class="btn primary" type="submit">Открыть</button>
|
||||
<input id="key-input" type="password" autocomplete="current-password" placeholder="Ключ администратора" aria-label="Ключ администратора">
|
||||
<button class="btn primary" type="submit" id="login-btn">Войти</button>
|
||||
</form>
|
||||
<p class="error" id="login-error" hidden></p>
|
||||
</section>
|
||||
@@ -176,14 +177,53 @@
|
||||
}
|
||||
const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта');
|
||||
|
||||
let key = new URLSearchParams(location.hash.slice(1)).get('k') || '';
|
||||
let data = null;
|
||||
|
||||
async function load() {
|
||||
$('login-error').hidden = true;
|
||||
function showLogin(message) {
|
||||
data = null;
|
||||
$('login').hidden = false;
|
||||
$('content').hidden = true;
|
||||
$('actions').hidden = true;
|
||||
$('login-error').textContent = message || '';
|
||||
$('login-error').hidden = !message;
|
||||
if (!message) $('key-input').focus();
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
});
|
||||
return { status: res.status, data: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
// Ключ обменивается на сессию (cookie ставит сервер); сам ключ нигде не сохраняется.
|
||||
async function login(key) {
|
||||
$('login-btn').disabled = true;
|
||||
try {
|
||||
const res = await fetch('api/admin/stats', { headers: { Authorization: `Bearer ${key}` }, cache: 'no-store' });
|
||||
if (res.status === 403) throw new Error('Ключ не подошёл. Проверьте ссылку или задайте ADMIN_KEY заново.');
|
||||
const { status, data: body } = await postJson('api/admin/login', { key });
|
||||
if (status === 200) { $('key-input').value = ''; return load(); }
|
||||
if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.');
|
||||
if (status === 401) return showLogin('Ключ не подошёл. Проверьте его и попробуйте ещё раз.');
|
||||
return showLogin(`Сервер ответил ошибкой ${status}. Попробуйте через минуту.`);
|
||||
} catch {
|
||||
return showLogin('Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.');
|
||||
} finally {
|
||||
$('login-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(expiredNotice) {
|
||||
try {
|
||||
const res = await fetch('api/admin/stats', { credentials: 'same-origin', cache: 'no-store' });
|
||||
if (res.status === 401) {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.');
|
||||
return showLogin(expiredNotice ? 'Вход закончился. Введите ключ ещё раз.' : '');
|
||||
}
|
||||
if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`);
|
||||
data = await res.json();
|
||||
$('login').hidden = true;
|
||||
@@ -191,10 +231,7 @@
|
||||
$('actions').hidden = false;
|
||||
render();
|
||||
} catch (err) {
|
||||
$('login').hidden = false;
|
||||
$('content').hidden = true;
|
||||
$('actions').hidden = true;
|
||||
if (key) { $('login-error').textContent = err.message; $('login-error').hidden = false; }
|
||||
showLogin(err.message.startsWith('Сервер') ? err.message : 'Нет связи с сервером. Попробуйте обновить через минуту.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +268,10 @@
|
||||
return `<div class="bar-row"><span class="label" title="${esc(cellName(c.cell))}"><b>${String(c.cell).padStart(2, '0')}</b>${esc(cellName(c.cell))}</span><div class="bar"><i style="width:${c.total / max * 100}%;background:${row.color}"></i></div><span class="n">${c.total}</span></div>`;
|
||||
}).join('')}</div>`).join('');
|
||||
|
||||
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.`;
|
||||
const until = data.session?.expiresAt
|
||||
? ` Вход действует до ${new Date(data.session.expiresAt).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}.`
|
||||
: '';
|
||||
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.${until}`;
|
||||
}
|
||||
|
||||
// CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта.
|
||||
@@ -270,15 +310,22 @@
|
||||
const b = e.target.closest('[data-csv]');
|
||||
if (b && data) download(`balance-bingo-${b.dataset.csv}-${new Date().toISOString().slice(0, 10)}.csv`, exporters[b.dataset.csv]());
|
||||
});
|
||||
$('reload').addEventListener('click', load);
|
||||
$('reload').addEventListener('click', () => load(true));
|
||||
$('logout').addEventListener('click', async () => {
|
||||
try { await postJson('api/admin/logout'); } catch { /* сессия всё равно истечёт сама */ }
|
||||
showLogin('');
|
||||
});
|
||||
$('login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
key = $('key-input').value.trim();
|
||||
history.replaceState(null, '', `#k=${encodeURIComponent(key)}`);
|
||||
load();
|
||||
const key = $('key-input').value.trim();
|
||||
if (key) login(key);
|
||||
});
|
||||
|
||||
load();
|
||||
// Ссылка для входа /admin#k=КЛЮЧ: сразу убираем ключ из адреса и истории, затем входим.
|
||||
const linkKey = new URLSearchParams(location.hash.slice(1)).get('k');
|
||||
if (location.hash) history.replaceState(null, '', location.pathname + location.search);
|
||||
if (linkKey) login(linkKey);
|
||||
else load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user