Files
ai_platform/lib/ops-session-revoke.js
dsyoon 073a8343dd feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영.
@xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 22:27:48 +09:00

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,
};