'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 isAdminCookieHeader(cookieHeader) { return roleOf(cookieHeader) === 'admin'; } function isAdmin(req) { return isAdminCookieHeader(req.headers.cookie); } 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, isAdminCookieHeader, isSameOrigin, isAllowedHost, requireAllowedHost, handleLogin, requireAuth, requireAdmin, requireSameOrigin, safeNext, };