Persist user login/logout audit in ncue_user
Add first_login_at and last_logout_at, ensure table exists at runtime, upsert user on /api/auth/sync, and record logout via /api/auth/logout from the client. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,9 @@ BEGIN
|
|||||||
name text,
|
name text,
|
||||||
picture text,
|
picture text,
|
||||||
provider text,
|
provider text,
|
||||||
|
first_login_at timestamptz,
|
||||||
last_login_at timestamptz,
|
last_login_at timestamptz,
|
||||||
|
last_logout_at timestamptz,
|
||||||
can_manage boolean NOT NULL DEFAULT false,
|
can_manage boolean NOT NULL DEFAULT false,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
@@ -25,3 +27,7 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- Backward-compatible migration (if table already exists)
|
||||||
|
ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS first_login_at timestamptz;
|
||||||
|
ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS last_logout_at timestamptz;
|
||||||
|
|
||||||
|
|||||||
40
script.js
40
script.js
@@ -666,6 +666,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendLogoutToServer(idToken) {
|
||||||
|
if (!idToken) return;
|
||||||
|
const cfg = getAuthConfig();
|
||||||
|
const payload = JSON.stringify({ t: Date.now() });
|
||||||
|
// Prefer sendBeacon to survive navigation
|
||||||
|
try {
|
||||||
|
const blob = new Blob([payload], { type: "application/json" });
|
||||||
|
const ok = navigator.sendBeacon("/api/auth/logout", blob);
|
||||||
|
if (ok) return;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Fallback fetch keepalive (best-effort)
|
||||||
|
try {
|
||||||
|
fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${idToken}`,
|
||||||
|
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
|
||||||
|
"X-Auth0-ClientId": cfg.auth0.clientId,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAllowedEmail(email) {
|
function isAllowedEmail(email) {
|
||||||
const { allowedEmails } = getAuthConfig();
|
const { allowedEmails } = getAuthConfig();
|
||||||
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
|
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
|
||||||
@@ -871,6 +901,13 @@
|
|||||||
if (auth.mode !== "enabled") return;
|
if (auth.mode !== "enabled") return;
|
||||||
// SDK가 있으면 SDK로, 없으면 수동 로그아웃
|
// SDK가 있으면 SDK로, 없으면 수동 로그아웃
|
||||||
if (auth.client) {
|
if (auth.client) {
|
||||||
|
try {
|
||||||
|
const claims = await auth.client.getIdTokenClaims();
|
||||||
|
const raw = claims && claims.__raw ? String(claims.__raw) : "";
|
||||||
|
if (raw) sendLogoutToServer(raw);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
auth.user = null;
|
auth.user = null;
|
||||||
auth.authorized = false;
|
auth.authorized = false;
|
||||||
updateAuthUi();
|
updateAuthUi();
|
||||||
@@ -882,6 +919,9 @@
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// manual token logout
|
||||||
|
const t = loadTokens();
|
||||||
|
if (t && t.id_token) sendLogoutToServer(t.id_token);
|
||||||
clearTokens();
|
clearTokens();
|
||||||
auth.user = null;
|
auth.user = null;
|
||||||
auth.authorized = false;
|
auth.authorized = false;
|
||||||
|
|||||||
60
server.js
60
server.js
@@ -76,8 +76,31 @@ async function verifyIdToken(idToken, { issuer, audience }) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureUserTable() {
|
||||||
|
// Create table if missing + add columns for upgrades
|
||||||
|
await pool.query(`
|
||||||
|
create table if not exists public.${TABLE} (
|
||||||
|
sub text primary key,
|
||||||
|
email text,
|
||||||
|
name text,
|
||||||
|
picture text,
|
||||||
|
provider text,
|
||||||
|
first_login_at timestamptz,
|
||||||
|
last_login_at timestamptz,
|
||||||
|
last_logout_at timestamptz,
|
||||||
|
can_manage boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`);
|
||||||
|
await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`);
|
||||||
|
await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`);
|
||||||
|
}
|
||||||
|
|
||||||
app.post("/api/auth/sync", async (req, res) => {
|
app.post("/api/auth/sync", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
await ensureUserTable();
|
||||||
const idToken = getBearer(req);
|
const idToken = getBearer(req);
|
||||||
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
|
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
|
||||||
|
|
||||||
@@ -97,22 +120,51 @@ app.post("/api/auth/sync", async (req, res) => {
|
|||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
insert into public.${TABLE}
|
insert into public.${TABLE}
|
||||||
(sub, email, name, picture, provider, last_login_at, updated_at)
|
(sub, email, name, picture, provider, first_login_at, last_login_at, updated_at)
|
||||||
values
|
values
|
||||||
($1, $2, $3, $4, $5, now(), now())
|
($1, $2, $3, $4, $5, now(), now(), now())
|
||||||
on conflict (sub) do update set
|
on conflict (sub) do update set
|
||||||
email = excluded.email,
|
email = excluded.email,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
picture = excluded.picture,
|
picture = excluded.picture,
|
||||||
provider = excluded.provider,
|
provider = excluded.provider,
|
||||||
|
first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at),
|
||||||
last_login_at = now(),
|
last_login_at = now(),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
returning can_manage
|
returning can_manage, first_login_at, last_login_at, last_logout_at
|
||||||
`;
|
`;
|
||||||
const r = await pool.query(q, [sub, email, name, picture, provider]);
|
const r = await pool.query(q, [sub, email, name, picture, provider]);
|
||||||
const canManage = Boolean(r.rows?.[0]?.can_manage);
|
const canManage = Boolean(r.rows?.[0]?.can_manage);
|
||||||
|
|
||||||
res.json({ ok: true, canManage });
|
res.json({ ok: true, canManage, user: r.rows?.[0] || null });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(401).json({ ok: false, error: "verify_failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureUserTable();
|
||||||
|
const idToken = getBearer(req);
|
||||||
|
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
|
||||||
|
|
||||||
|
const issuer = String(req.headers["x-auth0-issuer"] || "").trim();
|
||||||
|
const audience = String(req.headers["x-auth0-clientid"] || "").trim();
|
||||||
|
if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" });
|
||||||
|
|
||||||
|
const payload = await verifyIdToken(idToken, { issuer, audience });
|
||||||
|
const sub = String(payload.sub || "").trim();
|
||||||
|
if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" });
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
update public.${TABLE}
|
||||||
|
set last_logout_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
where sub = $1
|
||||||
|
returning last_logout_at
|
||||||
|
`;
|
||||||
|
const r = await pool.query(q, [sub]);
|
||||||
|
res.json({ ok: true, last_logout_at: r.rows?.[0]?.last_logout_at || null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(401).json({ ok: false, error: "verify_failed" });
|
res.status(401).json({ ok: false, error: "verify_failed" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user