- 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>
244 lines
8.6 KiB
JavaScript
244 lines
8.6 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const net = require('net');
|
|
const path = require('path');
|
|
const config = require('./config');
|
|
|
|
const COOKIE_NAME = 'pk_auth';
|
|
// ブラウザの上限が400日なので、アクセスのたびに延長する(実質無期限)
|
|
const COOKIE_MAX_AGE_SEC = 400 * 24 * 60 * 60;
|
|
|
|
// Cookie 署名用の秘密値。再起動してもログイン状態が保たれるようファイルに保存する。
|
|
// data/session-secret を削除して再起動すると、全員の入室状態を取り消せる。
|
|
function loadSecret() {
|
|
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
|
const file = path.join(__dirname, '..', 'data', 'session-secret');
|
|
try {
|
|
const s = fs.readFileSync(file, 'utf8').trim();
|
|
if (s.length >= 32) return s;
|
|
} catch { /* 初回 */ }
|
|
const secret = crypto.randomBytes(32).toString('hex');
|
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(file, secret, { mode: 0o600 });
|
|
return secret;
|
|
}
|
|
|
|
// 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 = {};
|
|
if (typeof header !== 'string') return out;
|
|
for (const part of header.split(';')) {
|
|
const i = part.indexOf('=');
|
|
if (i < 0) continue;
|
|
try {
|
|
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
} catch { /* 不正なエンコードは無視 */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function safeEqual(a, b) {
|
|
// 長さの違いも漏らさないようハッシュ同士を比較する
|
|
const ha = crypto.createHash('sha256').update(String(a)).digest();
|
|
const hb = crypto.createHash('sha256').update(String(b)).digest();
|
|
return crypto.timingSafeEqual(ha, hb);
|
|
}
|
|
|
|
// 'admin' / 'member' / null
|
|
function roleOf(cookieHeader) {
|
|
const value = parseCookies(cookieHeader)[COOKIE_NAME];
|
|
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 isAdmin(req) {
|
|
return roleOf(req.headers.cookie) === 'admin';
|
|
}
|
|
|
|
function setAuthCookie(req, res, role) {
|
|
const parts = [
|
|
`${COOKIE_NAME}=${TOKENS[role]}`,
|
|
'Path=/',
|
|
`Max-Age=${COOKIE_MAX_AGE_SEC}`,
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
];
|
|
if (req.secure) parts.push('Secure');
|
|
res.setHeader('Set-Cookie', parts.join('; '));
|
|
}
|
|
|
|
// ブラウザからのリクエストが同じサイト発かどうか(CSRF / Cross-Site WebSocket 対策)
|
|
function isSameOrigin(headers) {
|
|
const origin = headers.origin;
|
|
if (!origin) return false;
|
|
if (config.PUBLIC_ORIGIN) return origin === config.PUBLIC_ORIGIN && isAllowedHost(headers);
|
|
try {
|
|
return new URL(origin).host === headers.host;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// PUBLIC_ORIGIN 設定時は、そのドメイン宛て以外のリクエスト(IP直打ち等)を拒否する
|
|
const PUBLIC_HOST = config.PUBLIC_ORIGIN ? new URL(config.PUBLIC_ORIGIN).host : null;
|
|
function isAllowedHost(headers) {
|
|
return !PUBLIC_HOST || String(headers.host || '').toLowerCase() === PUBLIC_HOST;
|
|
}
|
|
|
|
function requireAllowedHost(req, res, next) {
|
|
if (isAllowedHost(req.headers)) return next();
|
|
res.status(421).type('text/plain').send('Misdirected Request');
|
|
}
|
|
|
|
// ---- 失敗回数制限 ----
|
|
|
|
// IPv6 は1人が /64 を丸ごと持っていることが多いので /64 単位でまとめる
|
|
function clientKey(ip) {
|
|
let addr = String(ip || '');
|
|
if (addr.startsWith('::ffff:')) addr = addr.slice(7);
|
|
if (net.isIPv6(addr)) {
|
|
const full = expandIPv6(addr);
|
|
return full ? full.slice(0, 4).join(':') + '::/64' : addr;
|
|
}
|
|
return addr;
|
|
}
|
|
|
|
function expandIPv6(addr) {
|
|
const [head, tail] = addr.split('::');
|
|
const h = head ? head.split(':') : [];
|
|
const t = tail !== undefined && tail ? tail.split(':') : [];
|
|
if (tail === undefined && h.length !== 8) return null;
|
|
const fill = new Array(8 - h.length - t.length).fill('0');
|
|
return [...h, ...fill, ...t];
|
|
}
|
|
|
|
class Window {
|
|
constructor(ms) {
|
|
this.ms = ms;
|
|
this.count = 0;
|
|
this.resetAt = Date.now() + ms;
|
|
}
|
|
get expired() {
|
|
return this.resetAt <= Date.now();
|
|
}
|
|
}
|
|
|
|
const perClient = new Map(); // clientKey -> Window
|
|
let global = new Window(config.AUTH_GLOBAL_WINDOW_MS);
|
|
|
|
function clientWindow(key) {
|
|
let w = perClient.get(key);
|
|
if (!w || w.expired) {
|
|
w = new Window(config.AUTH_FAILURE_WINDOW_MS);
|
|
perClient.set(key, w);
|
|
}
|
|
return w;
|
|
}
|
|
|
|
setInterval(() => {
|
|
for (const [k, w] of perClient) if (w.expired) perClient.delete(k);
|
|
}, 60 * 1000).unref();
|
|
|
|
function tooMany(res, w) {
|
|
const retryMin = Math.max(1, Math.ceil((w.resetAt - Date.now()) / 60000));
|
|
res.setHeader('Retry-After', String(retryMin * 60));
|
|
return res.status(429).json({ error: `試行回数が多すぎます。${retryMin}分後に再度お試しください。` });
|
|
}
|
|
|
|
function handleLogin(req, res) {
|
|
if (!isSameOrigin(req.headers)) return res.status(403).json({ error: 'forbidden' });
|
|
|
|
const key = clientKey(req.ip);
|
|
const w = clientWindow(key);
|
|
if (global.expired) global = new Window(config.AUTH_GLOBAL_WINDOW_MS);
|
|
if (w.count >= config.AUTH_MAX_FAILURES) return tooMany(res, w);
|
|
if (global.count >= config.AUTH_GLOBAL_MAX_FAILURES) return tooMany(res, global);
|
|
|
|
const input = typeof (req.body && req.body.key) === 'string' ? req.body.key.trim() : '';
|
|
if (!/^\d{6}$/.test(input)) {
|
|
return res.status(400).json({ error: '6桁の数字を入力してください。' });
|
|
}
|
|
// どちらも必ず比較する
|
|
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})`);
|
|
if (global.count === config.AUTH_GLOBAL_MAX_FAILURES) {
|
|
console.warn('[auth] 全体の失敗回数が上限に達したため、新規の鍵入力を一時停止します');
|
|
}
|
|
const left = config.AUTH_MAX_FAILURES - w.count;
|
|
return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
|
|
}
|
|
perClient.delete(key);
|
|
// 管理者がメンバー用のキーを入れ直しても管理者のまま
|
|
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) {
|
|
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' });
|
|
if (req.method === 'GET') return res.redirect('/key?next=' + encodeURIComponent(req.originalUrl));
|
|
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();
|
|
res.status(403).json({ error: 'forbidden' });
|
|
}
|
|
|
|
// オープンリダイレクト防止: 自サイト内の通常のパスだけ許可("//x" や "/\x" は外部扱いされるので拒否)
|
|
function safeNext(next) {
|
|
return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/';
|
|
}
|
|
|
|
module.exports = {
|
|
isAuthed,
|
|
isAdmin,
|
|
isAuthedCookieHeader,
|
|
isSameOrigin,
|
|
isAllowedHost,
|
|
requireAllowedHost,
|
|
handleLogin,
|
|
requireAuth,
|
|
requireAdmin,
|
|
requireSameOrigin,
|
|
safeNext,
|
|
};
|