/** * OPS 이메일 세션 전체 무효화(모든 디바이스 로그아웃) * 쿠키에 iat(발급 시각)를 넣고, DB sessions_revoked_at 이후 iat만 유효하게 한다. */ const REVOKE_FILE = "ops-session-revocations.json"; /** * @param {string} email * @returns {string} */ function normalizeEmail(email) { return String(email || "") .trim() .toLowerCase() .slice(0, 320); } /** * @param {import('pg').Pool | null | undefined} pgPool * @param {string} dataDir * @param {string} email * @returns {Promise} revoked at epoch ms, 0 if never revoked */ async function getSessionsRevokedAtMs(pgPool, dataDir, email) { const e = normalizeEmail(email); if (!e) return 0; if (pgPool) { try { const r = await pgPool.query( `SELECT sessions_revoked_at FROM ops_email_users WHERE email = $1`, [e] ); const ts = r.rows?.[0]?.sessions_revoked_at; return ts ? new Date(ts).getTime() : 0; } catch (err) { console.error("[ops-session-revoke] get failed:", err.message); return 0; } } try { const fs = require("fs"); const path = require("path"); const fp = path.join(dataDir, REVOKE_FILE); if (!fs.existsSync(fp)) return 0; const raw = fs.readFileSync(fp, "utf8"); const map = JSON.parse(raw); const v = map?.[e]; return typeof v === "number" && Number.isFinite(v) ? v : 0; } catch { return 0; } } /** * @param {import('pg').Pool | null | undefined} pgPool * @param {string} dataDir * @param {string} email * @param {number} iatMs * @returns {Promise} */ async function isOpsSessionRevoked(pgPool, dataDir, email, iatMs) { const revokedAt = await getSessionsRevokedAtMs(pgPool, dataDir, email); if (!revokedAt) return false; const iat = typeof iatMs === "number" && Number.isFinite(iatMs) ? iatMs : 0; return iat <= revokedAt; } /** * @param {import('pg').Pool | null | undefined} pgPool * @param {string} dataDir * @param {string} email * @returns {Promise<{ revokedAtMs: number }>} */ async function revokeAllOpsSessionsForEmail(pgPool, dataDir, email) { const e = normalizeEmail(email); if (!e) { throw new Error("email is required"); } const revokedAtMs = Date.now(); if (pgPool) { await pgPool.query( `INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count, sessions_revoked_at) VALUES ($1, NOW(), NOW(), 0, to_timestamp($2 / 1000.0)) ON CONFLICT (email) DO UPDATE SET sessions_revoked_at = EXCLUDED.sessions_revoked_at`, [e, revokedAtMs] ); return { revokedAtMs }; } const fs = require("fs"); const path = require("path"); const fp = path.join(dataDir, REVOKE_FILE); let map = {}; try { if (fs.existsSync(fp)) { map = JSON.parse(fs.readFileSync(fp, "utf8")) || {}; } } catch { map = {}; } map[e] = revokedAtMs; fs.mkdirSync(path.dirname(fp), { recursive: true }); fs.writeFileSync(fp, JSON.stringify(map, null, 2), "utf8"); return { revokedAtMs }; } module.exports = { getSessionsRevokedAtMs, isOpsSessionRevoked, revokeAllOpsSessionsForEmail, };