Files
Poker/server/auth.js
T
agomizi1cmandClaude Opus 5.5 e89d867f85 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>
2026-10-16 12:13:14 +09:00

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,
};