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:
1 parent
1e1ac4e91d
commit
e89d867f85
64 files changed
+477
-19
No files matched your search
+44
-15
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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() }));
|
||||
|
||||
|
||||
Reference in new issue
Block a user