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,
};
+8
View File
@@ -30,6 +30,13 @@ if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) {
process.exit(1);
}
// 管理者キー(任意)。入力すると管理者として入室でき、ハンドレンジ学習ページが見える
const ADMIN_KEY = process.env.ADMIN_KEY || null;
if (ADMIN_KEY && (!/^\d{6}$/.test(ADMIN_KEY) || ADMIN_KEY === ACCESS_KEY)) {
console.error('[config] ADMIN_KEY は ACCESS_KEY と異なる6桁の数字にしてください。');
process.exit(1);
}
module.exports = {
PORT: Number(process.env.PORT) || 4671,
@@ -43,6 +50,7 @@ module.exports = {
// 参加キー(6桁の数字)。.env の ACCESS_KEY
ACCESS_KEY,
ADMIN_KEY,
// 1テーブルの最大着席数
MAX_SEATS: 9,
+20 -2
View File
@@ -9,7 +9,9 @@ const auth = require('./auth');
const { TableManager } = require('./tables');
const PUBLIC = path.join(__dirname, '..', 'public');
const page = (file) => (req, res) => res.sendFile(path.join(PUBLIC, file));
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
const ADMIN = path.join(__dirname, '..', 'admin');
const page = (file, dir = PUBLIC) => (req, res) => res.sendFile(path.join(dir, file));
const app = express();
app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく
@@ -70,7 +72,9 @@ app.use('/assets', express.static(path.join(PUBLIC, 'assets'), { index: false, d
app.get('/favicon.ico', (req, res) => res.sendFile(path.join(PUBLIC, 'assets', 'favicon.ico')));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
app.get('/key', (req, res) => {
if (auth.isAuthed(req)) return res.redirect(auth.safeNext(req.query.next));
// 入室済みなら飛ばす。ただし /key?admin は管理者キーを入れるために開ける
const wantsAdmin = req.query.admin !== undefined && !auth.isAdmin(req);
if (auth.isAuthed(req) && !wantsAdmin) return res.redirect(auth.safeNext(req.query.next));
page('key.html')(req, res);
});
app.post('/api/auth', auth.handleLogin);
@@ -83,6 +87,20 @@ app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
app.use(
'/range/files',
auth.requireAdmin,
express.static(ADMIN, {
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'),
})
);
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list()));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));