diff --git a/db/schema.sql b/db/schema.sql index 6bae1c1..64f3dcc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -15,7 +15,9 @@ BEGIN 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() @@ -25,3 +27,7 @@ BEGIN END IF; 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; + diff --git a/script.js b/script.js index 83deebf..e414310 100644 --- a/script.js +++ b/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) { const { allowedEmails } = getAuthConfig(); if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용 @@ -871,6 +901,13 @@ if (auth.mode !== "enabled") return; // SDK가 있으면 SDK로, 없으면 수동 로그아웃 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.authorized = false; updateAuthUi(); @@ -882,6 +919,9 @@ }); return; } + // manual token logout + const t = loadTokens(); + if (t && t.id_token) sendLogoutToServer(t.id_token); clearTokens(); auth.user = null; auth.authorized = false; diff --git a/server.js b/server.js index 70192c4..13a0806 100644 --- a/server.js +++ b/server.js @@ -76,8 +76,31 @@ async function verifyIdToken(idToken, { issuer, audience }) { 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) => { try { + await ensureUserTable(); const idToken = getBearer(req); 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 = ` 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 - ($1, $2, $3, $4, $5, now(), now()) + ($1, $2, $3, $4, $5, now(), now(), now()) on conflict (sub) do update set email = excluded.email, name = excluded.name, picture = excluded.picture, provider = excluded.provider, + first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at), last_login_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 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) { res.status(401).json({ ok: false, error: "verify_failed" }); }