Files
Poker/server/auth.js
T
agomizi1cmandClaude Opus 5.5 b2c91062f1 Initial commit: private Texas Hold'em ring game site (lobby + table stub)
- 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits
- Lobby with saved player name and live table list
- Table creation (name, starting stack, blinds, blind timer)
- Table play screen stub (seats, button/SB/BB, phase, blinds, timer)
- Security hardening: strict CSP, origin checks, host pinning, input sanitizing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:51:15 +09:00

215 lines
7.3 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 はすべて無効になる
const AUTH_TOKEN = crypto
.createHmac('sha256', loadSecret())
.update('access:' + config.ACCESS_KEY)
.digest('base64url');
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);
}
function isAuthedCookieHeader(cookieHeader) {
const value = parseCookies(cookieHeader)[COOKIE_NAME];
return Boolean(value) && safeEqual(value, AUTH_TOKEN);
}
function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res) {
const parts = [
`${COOKIE_NAME}=${AUTH_TOKEN}`,
'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桁の数字を入力してください。' });
}
if (!safeEqual(input, config.ACCESS_KEY)) {
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);
console.log(`[auth] 入室 from ${key}`);
setAuthCookie(req, res);
res.json({ ok: true });
}
// /key 以外のページ・API を保護する
function requireAuth(req, res, next) {
if (isAuthed(req)) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res);
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();
}
// 状態を変える 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,
isAuthedCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
handleLogin,
requireAuth,
requireSameOrigin,
safeNext,
};