xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
116 lines
3.1 KiB
JavaScript
116 lines
3.1 KiB
JavaScript
/**
|
|
* 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<number>} 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<boolean>}
|
|
*/
|
|
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,
|
|
};
|