Add admin role with an admin-only hand range study page

- Optional ADMIN_KEY (6 digits, distinct from ACCESS_KEY): entering it on the
  key screen signs in as admin (role-specific HMAC cookie; member cookies
  stay valid). Logged-in members can open /key?admin to enter it.
- /range (range chart + color/category quiz, ported from poker-hand and
  restyled to the site design) and its files under admin/ are served only to
  admins; everyone else gets 404. Card images cached privately for a day.
- Lobby shows a link to the page for admins only (GET /api/me).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-16 12:13:14 +09:00
1 parent 1e1ac4e91d
commit e89d867f85
64 files changed
+477 -19

No files matched your search

+44 -15
View File
@@ -25,11 +25,13 @@ function loadSecret() {
return secret;
}
// 鍵を変えると既存の Cookie はすべて無効になる
const AUTH_TOKEN = crypto
.createHmac('sha256', loadSecret())
.update('access:' + config.ACCESS_KEY)
.digest('base64url');
// Cookie の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる
const SECRET = loadSecret();
const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url');
const TOKENS = {
member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効)
admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null,
};
function parseCookies(header) {
const out = {};
@@ -51,18 +53,31 @@ function safeEqual(a, b) {
return crypto.timingSafeEqual(ha, hb);
}
function isAuthedCookieHeader(cookieHeader) {
// 'admin' / 'member' / null
function roleOf(cookieHeader) {
const value = parseCookies(cookieHeader)[COOKIE_NAME];
return Boolean(value) && safeEqual(value, AUTH_TOKEN);
if (!value) return null;
// どちらも必ず比較する(どちらに一致したかを処理時間から推測させない)
const admin = TOKENS.admin ? safeEqual(value, TOKENS.admin) : false;
const member = safeEqual(value, TOKENS.member);
return admin ? 'admin' : member ? 'member' : null;
}
function isAuthedCookieHeader(cookieHeader) {
return roleOf(cookieHeader) !== null;
}
function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res) {
function isAdmin(req) {
return roleOf(req.headers.cookie) === 'admin';
}
function setAuthCookie(req, res, role) {
const parts = [
`${COOKIE_NAME}=${AUTH_TOKEN}`,
`${COOKIE_NAME}=${TOKENS[role]}`,
'Path=/',
`Max-Age=${COOKIE_MAX_AGE_SEC}`,
'HttpOnly',
@@ -163,7 +178,10 @@ function handleLogin(req, res) {
if (!/^\d{6}$/.test(input)) {
return res.status(400).json({ error: '6桁の数字を入力してください。' });
}
if (!safeEqual(input, config.ACCESS_KEY)) {
// どちらも必ず比較する
const adminMatch = config.ADMIN_KEY ? safeEqual(input, config.ADMIN_KEY) : false;
const memberMatch = safeEqual(input, config.ACCESS_KEY);
if (!adminMatch && !memberMatch) {
w.count++;
global.count++;
console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`);
@@ -174,15 +192,18 @@ function handleLogin(req, res) {
return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
}
perClient.delete(key);
console.log(`[auth] 入室 from ${key}`);
setAuthCookie(req, res);
res.json({ ok: true });
// 管理者がメンバー用のキーを入れ直しても管理者のまま
const role = adminMatch || isAdmin(req) ? 'admin' : 'member';
console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`);
setAuthCookie(req, res, role);
res.json({ ok: true, admin: role === 'admin' });
}
// /key 以外のページ・API を保護する
function requireAuth(req, res, next) {
if (isAuthed(req)) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res);
const role = roleOf(req.headers.cookie);
if (role) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role);
return next();
}
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
@@ -190,6 +211,12 @@ function requireAuth(req, res, next) {
res.status(401).end();
}
// 管理者専用。管理者以外にはページがあること自体を知らせない(404)
function requireAdmin(req, res, next) {
if (isAdmin(req)) return next();
res.status(404).type('text/plain').send('Not Found');
}
// 状態を変える API は同じサイトからのみ受け付ける
function requireSameOrigin(req, res, next) {
if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next();
@@ -203,12 +230,14 @@ function safeNext(next) {
module.exports = {
isAuthed,
isAdmin,
isAuthedCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
handleLogin,
requireAuth,
requireAdmin,
requireSameOrigin,
safeNext,
};