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
+81
-8
@@ -5,7 +5,9 @@
|
||||
// GET /api/me?d=<id> → отметки и ответ опроса этого устройства за текущую неделю
|
||||
// POST /api/mark {d, week, cell, on} → отметить / снять клетку
|
||||
// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?»
|
||||
// GET /api/admin/stats → статистика (заголовок Authorization: Bearer <ADMIN_KEY>)
|
||||
// POST /api/admin/login {key} → проверяет ключ, ставит cookie сессии (HttpOnly, SameSite=Strict)
|
||||
// POST /api/admin/logout → завершает сессию
|
||||
// GET /api/admin/stats → статистика (cookie сессии или заголовок Authorization: Bearer <ADMIN_KEY>)
|
||||
// GET /healthz → проверка живости для Docker
|
||||
//
|
||||
// Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса.
|
||||
@@ -14,7 +16,7 @@
|
||||
import http from 'node:http';
|
||||
import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { join, extname, relative, sep } from 'node:path';
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto';
|
||||
import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
@@ -39,6 +41,8 @@ const CFG = {
|
||||
goal: parseInt(process.env.GOAL || '500', 10),
|
||||
adminKey: readAdminKey(),
|
||||
backupKeep: parseInt(process.env.BACKUP_KEEP || '14', 10),
|
||||
sessionHours: Number(process.env.ADMIN_SESSION_HOURS || '8'),
|
||||
cookieSecure: !['0', 'false', 'no'].includes(String(process.env.COOKIE_SECURE || '').toLowerCase()),
|
||||
};
|
||||
|
||||
function fail(msg) {
|
||||
@@ -50,6 +54,7 @@ if (CFG.start > CFG.end) fail('SEASON_START позже SEASON_END.');
|
||||
if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.');
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: CFG.tz }); } catch { fail(`неизвестный часовой пояс TIMEZONE=${CFG.tz}.`); }
|
||||
if (CFG.adminKey && CFG.adminKey.length < 16) fail('ADMIN_KEY короче 16 символов.');
|
||||
if (!(CFG.sessionHours > 0 && CFG.sessionHours <= 720)) fail('ADMIN_SESSION_HOURS должен быть больше 0 и не больше 720.');
|
||||
|
||||
// ---------- база ----------
|
||||
|
||||
@@ -175,9 +180,30 @@ async function handleApi(req, res, url) {
|
||||
return json(res, 200, { ok: true, answer });
|
||||
}
|
||||
|
||||
if (path === '/api/admin/login' && method === 'POST') {
|
||||
if (!CFG.adminKey) return json(res, 403, { error: 'admin_disabled' });
|
||||
const body = await readJson(req);
|
||||
if (body === TOO_LARGE) return tooLarge(res);
|
||||
if (!body || !keyMatches(String(body.key || ''))) {
|
||||
await new Promise((r) => setTimeout(r, 500)); // замедляем подбор
|
||||
return json(res, 401, { error: 'bad_key' });
|
||||
}
|
||||
const { token, expires } = createSession();
|
||||
res.setHeader('Set-Cookie', sessionCookie(token, CFG.sessionHours * 3600));
|
||||
return json(res, 200, { ok: true, expiresAt: new Date(expires).toISOString() });
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logout' && method === 'POST') {
|
||||
const token = readCookie(req, COOKIE);
|
||||
if (token) sessions.delete(tokenId(token));
|
||||
res.setHeader('Set-Cookie', sessionCookie('', 0));
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (path === '/api/admin/stats' && method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 403, { error: 'forbidden' });
|
||||
return json(res, 200, adminStats());
|
||||
const auth = adminAuth(req);
|
||||
if (!auth) return json(res, 401, { error: CFG.adminKey ? 'unauthorized' : 'admin_disabled' });
|
||||
return json(res, 200, { ...adminStats(), session: auth.expires ? { expiresAt: new Date(auth.expires).toISOString() } : null });
|
||||
}
|
||||
|
||||
return json(res, 404, { error: 'not_found' });
|
||||
@@ -221,12 +247,58 @@ function adminStats() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- вход в админку ----------
|
||||
//
|
||||
// Ключ вводится один раз (или приходит по ссылке /admin#k=…, страница сразу убирает его из адреса).
|
||||
// Сервер выдаёт случайный токен в cookie: HttpOnly (недоступен скриптам), SameSite=Strict, Secure.
|
||||
// Path не задаётся: браузер берёт путь запроса (/api/admin или /<папка>/api/admin за прокси),
|
||||
// так что cookie уходит только на адреса админского API.
|
||||
// Срок жизни фиксированный — ADMIN_SESSION_HOURS с момента входа, без продления.
|
||||
// Сессии хранятся в памяти процесса (в виде хешей токенов): перезапуск контейнера завершает все сессии.
|
||||
|
||||
const COOKIE = 'bingo_admin';
|
||||
const MAX_SESSIONS = 200;
|
||||
const sessions = new Map(); // sha256(token) → время окончания, мс
|
||||
|
||||
const sha = (s) => createHash('sha256').update(s).digest();
|
||||
function isAdmin(req) {
|
||||
if (!CFG.adminKey) return false; // ключ не задан — админка выключена
|
||||
const tokenId = (token) => sha(token).toString('hex');
|
||||
const keyMatches = (given) => Boolean(CFG.adminKey) && timingSafeEqual(sha(given), sha(CFG.adminKey));
|
||||
|
||||
function createSession() {
|
||||
const now = Date.now();
|
||||
for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id);
|
||||
while (sessions.size >= MAX_SESSIONS) sessions.delete(sessions.keys().next().value); // самая старая
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const expires = now + CFG.sessionHours * 3600 * 1000;
|
||||
sessions.set(tokenId(token), expires);
|
||||
return { token, expires };
|
||||
}
|
||||
|
||||
function sessionCookie(token, maxAgeSec) {
|
||||
return `${COOKIE}=${token}; Max-Age=${Math.floor(maxAgeSec)}; HttpOnly; SameSite=Strict${CFG.cookieSecure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
function readCookie(req, name) {
|
||||
for (const part of (req.headers.cookie || '').split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Возвращает { expires } для cookie-сессии, { expires: null } для Bearer-ключа (скрипты, curl) или null.
|
||||
function adminAuth(req) {
|
||||
if (!CFG.adminKey) return null; // ключ не задан — админка выключена
|
||||
const token = readCookie(req, COOKIE);
|
||||
if (token) {
|
||||
const id = tokenId(token);
|
||||
const exp = sessions.get(id);
|
||||
if (exp && exp > Date.now()) return { expires: exp };
|
||||
sessions.delete(id);
|
||||
}
|
||||
const auth = req.headers.authorization || '';
|
||||
const given = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
||||
return timingSafeEqual(sha(given), sha(CFG.adminKey));
|
||||
if (auth.startsWith('Bearer ') && keyMatches(auth.slice(7))) return { expires: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- статика ----------
|
||||
@@ -408,6 +480,7 @@ server.listen(CFG.port, () => {
|
||||
const s = season();
|
||||
console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`);
|
||||
if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.');
|
||||
else console.log(`Вход в /admin действует ${CFG.sessionHours} ч.${CFG.cookieSecure ? '' : ' Внимание: COOKIE_SECURE выключен.'}`);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
|
||||
Reference in new issue
Block a user