'use strict'; /** * ログイン機能付き 会員ページ * * - 依存パッケージなし(Node.js 18 以上の標準モジュールのみ) * - 起動: `node server.js` → http://127.0.0.1:3000 * * 主な機能: 新規会員登録 / ログイン / ログアウト / 会員限定ページ / パスワード変更 * 主な安全対策: scrypt によるパスワードハッシュ、HttpOnly セッション Cookie、 * ログイン時のセッション ID 再発行、CSRF トークン、Origin 検査、試行回数制限、 * セキュリティヘッダー(CSP など)、HTML エスケープ */ const http = require('http'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); // --------------------------------------------------------------------------- // 設定(環境変数で上書きできます) // --------------------------------------------------------------------------- const PORT = Number(process.env.PORT) || 3000; const HOST = process.env.HOST || '127.0.0.1'; const SITE_NAME = process.env.SITE_NAME || 'メンバーズ'; const COOKIE_SECURE = process.env.COOKIE_SECURE === '1'; // HTTPS で公開するときは 1 const TRUST_PROXY = process.env.TRUST_PROXY === '1'; // リバースプロキシの背後に置くときは 1 const DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : path.join(__dirname, 'data'); const USERS_FILE = path.join(DATA_DIR, 'users.json'); const CSS_FILE = path.join(__dirname, 'public', 'style.css'); const SESSION_COOKIE = COOKIE_SECURE ? '__Host-sid' : 'sid'; const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 分操作がなければ失効 const SESSION_ABSOLUTE_MS = 12 * 60 * 60 * 1000; // ログインから最長 12 時間 const MAX_SESSIONS = 20000; const MAX_BODY_BYTES = 8 * 1024; const NAME_MAX = 40; const EMAIL_MAX = 254; const PASSWORD_MIN = 8; const PASSWORD_MAX = 128; const MINUTE = 60 * 1000; const LIMITS = { loginEmail: { max: 5, windowMs: 15 * MINUTE }, // 同じメールアドレスへの失敗 loginIp: { max: 30, windowMs: 15 * MINUTE }, // 同じ IP からの失敗 register: { max: 20, windowMs: 60 * MINUTE }, // 同じ IP からの登録操作 password: { max: 5, windowMs: 15 * MINUTE }, // パスワード変更時の現在パスワード誤り }; const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 }; // 会員限定ページに表示するお知らせ。自由に書き換えてください。 const MEMBER_NEWS = [ { date: '2026-10-16', title: '会員ページを公開しました', body: 'このページは、ログインした会員の方だけがご覧いただけます。', }, { date: '2026-10-10', title: '会員限定コンテンツのご案内', body: '今後、会員の方向けの資料やお知らせをこちらに掲載していきます。', }, { date: '2026-10-01', title: 'パスワード管理のお願い', body: 'ほかのサービスと同じパスワードの使い回しは避け、定期的な見直しをおすすめします。', }, ]; // --------------------------------------------------------------------------- // 会員データ(data/users.json に保存) // --------------------------------------------------------------------------- let db = { users: [] }; function loadUsers() { fs.mkdirSync(DATA_DIR, { recursive: true }); try { const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); if (parsed && Array.isArray(parsed.users)) db = parsed; } catch (err) { // ファイルがなければ空で開始。壊れている場合は上書きしないよう起動を止める。 if (err.code !== 'ENOENT') throw err; } } function saveUsers() { const json = JSON.stringify(db, null, 2); const tmp = `${USERS_FILE}.${process.pid}.tmp`; fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 }); try { fs.renameSync(tmp, USERS_FILE); } catch (err) { // OneDrive などがファイルを掴んでいて置き換えに失敗した場合は直接書き込む fs.writeFileSync(USERS_FILE, json, { encoding: 'utf8', mode: 0o600 }); try { fs.unlinkSync(tmp); } catch (_) { /* 無視 */ } } } const findUserByEmail = (email) => db.users.find((u) => u.email === email) || null; const findUserById = (id) => db.users.find((u) => u.id === id) || null; // --------------------------------------------------------------------------- // パスワードハッシュ(scrypt) // --------------------------------------------------------------------------- function scryptAsync(password, salt, keylen, opts) { return new Promise((resolve, reject) => { crypto.scrypt(password, salt, keylen, opts, (err, key) => (err ? reject(err) : resolve(key))); }); } async function hashPassword(password) { const salt = crypto.randomBytes(16); const key = await scryptAsync(password, salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT.maxmem, }); return ['scrypt', SCRYPT.N, SCRYPT.r, SCRYPT.p, salt.toString('base64'), key.toString('base64')].join('$'); } async function verifyPassword(password, stored) { const parts = String(stored || '').split('$'); if (parts.length !== 6 || parts[0] !== 'scrypt') return false; const [, N, r, p, saltB64, keyB64] = parts; const expected = Buffer.from(keyB64, 'base64'); const key = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length, { N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem, }); return key.length === expected.length && crypto.timingSafeEqual(key, expected); } // 存在しないメールアドレスでも同じだけ時間をかけ、登録有無を推測されにくくする const dummyHashPromise = hashPassword(crypto.randomBytes(16).toString('hex')); // --------------------------------------------------------------------------- // セッション(メモリ上に保持。サーバー再起動で全員ログアウト) // --------------------------------------------------------------------------- const sessions = new Map(); function isExpired(s, now) { return now - s.lastSeen > SESSION_IDLE_MS || now - s.createdAt > SESSION_ABSOLUTE_MS; } function newSession(userId = null) { if (sessions.size >= MAX_SESSIONS) pruneSessions(); while (sessions.size >= MAX_SESSIONS) { let victim = null; for (const s of sessions.values()) { if (!s.userId) { victim = s.id; break; } } sessions.delete(victim || sessions.keys().next().value); } const now = Date.now(); const s = { id: crypto.randomBytes(32).toString('base64url'), userId, csrf: crypto.randomBytes(32).toString('base64url'), createdAt: now, lastSeen: now, flash: null, }; sessions.set(s.id, s); return s; } function setSessionCookie(res, s) { const parts = [`${SESSION_COOKIE}=${s.id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']; if (COOKIE_SECURE) parts.push('Secure'); res.setHeader('Set-Cookie', parts.join('; ')); } function parseCookies(header) { const out = Object.create(null); if (!header) return out; for (const part of header.split(';')) { const i = part.indexOf('='); if (i < 0) continue; const k = part.slice(0, i).trim(); const v = part.slice(i + 1).trim(); if (k && !(k in out)) out[k] = v; } return out; } function loadSession(req, res) { const id = parseCookies(req.headers.cookie)[SESSION_COOKIE]; const now = Date.now(); let s = id ? sessions.get(id) : null; if (s && isExpired(s, now)) { sessions.delete(s.id); s = null; } if (s) { s.lastSeen = now; return s; } s = newSession(null); setSessionCookie(res, s); return s; } // ログイン状態が変わるときはセッション ID を作り直す(セッション固定攻撃対策) function startSession(ctx, userId) { sessions.delete(ctx.session.id); const s = newSession(userId); setSessionCookie(ctx.res, s); ctx.session = s; return s; } function takeFlash(session) { const f = session.flash; session.flash = null; return f; } // --------------------------------------------------------------------------- // 試行回数制限 // --------------------------------------------------------------------------- const attempts = new Map(); function lockedFor(key, limit) { const e = attempts.get(key); const now = Date.now(); if (e && e.resetAt > now && e.count >= limit.max) return e.resetAt - now; return 0; } function recordAttempt(key, limit) { const now = Date.now(); const e = attempts.get(key); if (!e || e.resetAt <= now) attempts.set(key, { count: 1, resetAt: now + limit.windowMs }); else e.count += 1; } function tooManyMessage(waitMs) { return `試行回数が多すぎます。約${Math.max(1, Math.ceil(waitMs / MINUTE))}分後に再度お試しください。`; } function pruneSessions() { const now = Date.now(); for (const [id, s] of sessions) if (isExpired(s, now)) sessions.delete(id); for (const [k, e] of attempts) if (e.resetAt <= now) attempts.delete(k); } setInterval(pruneSessions, MINUTE).unref(); // --------------------------------------------------------------------------- // リクエスト処理の補助 // --------------------------------------------------------------------------- function httpError(status) { const err = new Error(`HTTP ${status}`); err.status = status; return err; } function readBody(req) { return new Promise((resolve, reject) => { const declared = Number(req.headers['content-length']); if (declared > MAX_BODY_BYTES) { reject(httpError(413)); req.resume(); return; } const chunks = []; let size = 0; let tooLarge = false; req.on('data', (chunk) => { if (tooLarge) return; size += chunk.length; if (size > MAX_BODY_BYTES) { tooLarge = true; chunks.length = 0; } else { chunks.push(chunk); } }); req.on('end', () => (tooLarge ? reject(httpError(413)) : resolve(Buffer.concat(chunks).toString('utf8')))); req.on('error', reject); }); } async function readForm(req) { const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (type !== 'application/x-www-form-urlencoded') throw httpError(415); return new URLSearchParams(await readBody(req)); } function str(form, name) { const v = form.get(name); return v == null ? '' : String(v); } function clientIp(req) { if (TRUST_PROXY) { const xff = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean); if (xff.length) return xff[xff.length - 1]; } return req.socket.remoteAddress || 'unknown'; } function sameOrigin(req) { if (req.headers['sec-fetch-site'] === 'cross-site') return false; const origin = req.headers.origin; if (!origin) return true; // Origin を送らない古いブラウザ。CSRF トークンで守る。 try { return new URL(origin).host === req.headers.host; } catch (_) { return false; } } function csrfValid(ctx, form) { const a = Buffer.from(str(form, '_csrf')); const b = Buffer.from(ctx.session.csrf); return a.length === b.length && crypto.timingSafeEqual(a, b); } function csrfFailed(ctx, backTo) { ctx.session.flash = { type: 'error', text: 'ページの有効期限が切れたため、操作を完了できませんでした。もう一度お試しください。', }; redirect(ctx.res, backTo); } function setSecurityHeaders(res) { res.setHeader( 'Content-Security-Policy', "default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" ); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'same-origin'); res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); if (COOKIE_SECURE) res.setHeader('Strict-Transport-Security', 'max-age=31536000'); } function sendHtml(res, status, html) { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', }); res.end(html); } function redirect(res, location) { res.writeHead(303, { Location: location, 'Cache-Control': 'no-store' }); res.end(); } function serveCss(res) { fs.readFile(CSS_FILE, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not found'); return; } res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(data); }); } // --------------------------------------------------------------------------- // 入力チェック // --------------------------------------------------------------------------- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const CONTROL_RE = /[\u0000-\u001f\u007f]/; function normalizeEmail(v) { return String(v || '').trim().toLowerCase(); } function charLength(s) { return [...s].length; } function passwordErrors(password, confirm, label) { const errors = []; const len = charLength(password); if (len < PASSWORD_MIN) errors.push(`${label}は${PASSWORD_MIN}文字以上で入力してください。`); else if (len > PASSWORD_MAX) errors.push(`${label}は${PASSWORD_MAX}文字以内で入力してください。`); if (password !== confirm) errors.push(`${label}(確認用)が一致しません。`); return errors; } // --------------------------------------------------------------------------- // HTML テンプレート // --------------------------------------------------------------------------- const ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ESC_MAP[c]); const dateTimeFmt = new Intl.DateTimeFormat('ja-JP', { timeZone: 'Asia/Tokyo', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); const dateFmt = new Intl.DateTimeFormat('ja-JP', { timeZone: 'Asia/Tokyo', year: 'numeric', month: 'long', day: 'numeric', }); function fmt(iso, withTime = true) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '—'; return (withTime ? dateTimeFmt : dateFmt).format(d); } const FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231f6f5c'/%3E%3Cpath d='M11 15v-3a5 5 0 0 1 10 0v3' fill='none' stroke='%23fff' stroke-width='2.4'/%3E%3Crect x='8' y='15' width='16' height='11' rx='2.5' fill='%23fff'/%3E%3C/svg%3E"; function csrfField(ctx) { return ``; } function noticeBlock(type, text) { const role = type === 'error' ? 'alert' : 'status'; return `
${esc(text)}
入力内容を確認してください。
${esc(hint)}
` : ''}${esc(n.body)}
会員ページ
${u.previousLoginAt ? `前回のログイン:${esc(fmt(u.previousLoginAt))}` : 'はじめてのログインです。'}
変更すると、ほかの端末でのログインはすべて解除されます。