diff --git a/server/src/auth/router.ts b/server/src/auth/router.ts index 00210e6..8f19236 100644 --- a/server/src/auth/router.ts +++ b/server/src/auth/router.ts @@ -66,7 +66,14 @@ authRouter.get("/callback", async (req, res, next) => { await upsertUserFromLogin({ sub: claims.sub, email, name }); delete req.session.pendingAuth; - req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token }; + req.session.user = { + sub: claims.sub, + email, + name, + idToken: tokens.id_token, + ip: req.ip, + userAgent: req.headers["user-agent"], + }; req.session.save((err) => { if (err) return next(err); res.redirect("/"); diff --git a/server/src/index.ts b/server/src/index.ts index 76ae8db..31ca97d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ import { agentReportRouter } from "./routes/agentReport.js"; import { integrationsRouter } from "./routes/integrations.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; +import { sessionsRouter } from "./routes/sessions.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -82,6 +83,7 @@ app.use("/api/agent/report", agentReportRouter); app.use("/api/integrations", integrationsRouter); app.use("/api/settings", settingsRouter); app.use("/api/search", searchRouter); +app.use("/api/sessions", sessionsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/sessions.ts b/server/src/routes/sessions.ts new file mode 100644 index 0000000..d202be7 --- /dev/null +++ b/server/src/routes/sessions.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { listSessions, destroySession } from "../services/sessionStore.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const sessionsRouter = Router(); + +sessionsRouter.use(requireAuth, requireRole("admin")); + +sessionsRouter.get("/", asyncHandler(async (req, res) => { + const sessions = await listSessions(); + res.json({ sessions, currentSessionId: req.sessionID }); +})); + +sessionsRouter.delete("/:id", asyncHandler(async (req, res) => { + await destroySession(req.params.id); + await recordAudit({ + actor: req.currentUser!, + category: "session", + action: "revoke", + targetType: "session", + targetId: req.params.id, + }); + res.status(204).end(); +})); diff --git a/server/src/services/sessionStore.ts b/server/src/services/sessionStore.ts new file mode 100644 index 0000000..e985fdd --- /dev/null +++ b/server/src/services/sessionStore.ts @@ -0,0 +1,84 @@ +import session from "express-session"; +import FileStoreFactory from "session-file-store"; +import { db } from "../db/client.js"; +import { users, type UserRole } from "../db/schema.js"; +import { resolveDataPath } from "../paths.js"; +import { env } from "../env.js"; + +// A second FileStore instance pointed at the same directory/options as the one +// index.ts hands to express-session — session-file-store is a stateless +// wrapper around that directory, so a fresh instance reads/writes the exact +// same files. Needed because express-session's own Store type doesn't +// declare list()/get()/destroy() with useful signatures for this purpose. +interface FileStoreWithList extends session.Store { + list(callback: (err: unknown, files?: string[]) => void): void; +} + +const FileStore = FileStoreFactory(session); +const sessionDir = resolveDataPath(env.sessionDir); +const store = new FileStore({ path: sessionDir, logFn: () => {} }) as FileStoreWithList; + +export interface SessionSummary { + id: string; + sub: string; + email: string | null; + name: string | null; + role: UserRole | null; // null if the user row behind this session no longer exists + ip: string | null; + userAgent: string | null; + lastAccess: string; // ISO + expiresAt: string | null; // ISO, from the session cookie +} + +function listFiles(): Promise { + return new Promise((resolve, reject) => { + store.list((err, files) => (err ? reject(err) : resolve(files ?? []))); + }); +} + +function getSession(sessionId: string): Promise { + return new Promise((resolve, reject) => { + store.get(sessionId, (err, sess) => (err ? reject(err) : resolve(sess ?? null))); + }); +} + +export function destroySession(sessionId: string): Promise { + return new Promise((resolve, reject) => { + store.destroy(sessionId, (err) => (err ? reject(err) : resolve())); + }); +} + +/** Every live (non-expired) session with a completed login, newest-active first. */ +export async function listSessions(): Promise { + const files = await listFiles(); + const roleBySub = new Map(); + for (const u of await db.select({ oidcSub: users.oidcSub, role: users.role }).from(users)) { + roleBySub.set(u.oidcSub, u.role); + } + + const results: SessionSummary[] = []; + for (const file of files) { + const id = file.replace(/\.json$/, ""); + let sess: session.SessionData | null; + try { + sess = await getSession(id); + } catch { + continue; // corrupted/unreadable file — skip rather than fail the whole list + } + // No `user` means an incomplete login (mid-OIDC-flow `pendingAuth` only) or an expired session store.get() already nulled out. + if (!sess?.user) continue; + const lastAccess = (sess as unknown as { __lastAccess?: number }).__lastAccess; + results.push({ + id, + sub: sess.user.sub, + email: sess.user.email ?? null, + name: sess.user.name ?? null, + role: roleBySub.get(sess.user.sub) ?? null, + ip: sess.user.ip ?? null, + userAgent: sess.user.userAgent ?? null, + lastAccess: lastAccess ? new Date(lastAccess).toISOString() : new Date(0).toISOString(), + expiresAt: sess.cookie?.expires ? new Date(sess.cookie.expires).toISOString() : null, + }); + } + return results.sort((a, b) => b.lastAccess.localeCompare(a.lastAccess)); +} diff --git a/server/src/types/express-session.d.ts b/server/src/types/express-session.d.ts index df77925..161b284 100644 --- a/server/src/types/express-session.d.ts +++ b/server/src/types/express-session.d.ts @@ -7,6 +7,8 @@ declare module "express-session" { email?: string; name?: string; idToken?: string; + ip?: string; + userAgent?: string; }; pendingAuth?: { codeVerifier: string; diff --git a/web/src/App.tsx b/web/src/App.tsx index fdf4d08..b037360 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/c import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Users from "./pages/Users"; +import Sessions from "./pages/Sessions"; import AuditLog from "./pages/AuditLog"; import DiagLog from "./pages/DiagLog"; import Secrets from "./pages/Secrets"; @@ -99,6 +100,14 @@ export default function App() { } /> + + + + } + /> request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"), + revoke: (id: string) => request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }), + }, auditLog: { list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`), }, diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index f2e4839..bafb938 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -14,6 +14,7 @@ import { IconServerCog, IconDatabase, IconUsers, + IconDevices, IconHistory, IconStethoscope, IconSettings, @@ -46,6 +47,7 @@ const NAV_ITEMS: NavItem[] = [ { to: "/gitea", label: "Gitea", icon: }, { to: "/integrations", label: "Integrations", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, + { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, { to: "/diag-log", label: "Diagnostic Log", icon: , minRole: "admin" }, { to: "/settings", label: "Settings", icon: , minRole: "admin" }, diff --git a/web/src/pages/Sessions.tsx b/web/src/pages/Sessions.tsx new file mode 100644 index 0000000..e138e66 --- /dev/null +++ b/web/src/pages/Sessions.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { api, type SessionSummary } from "../api/client"; +import { formatDateTime } from "../utils/date"; +import { useSortable } from "../hooks/useSortable"; +import SortableTh from "../components/SortableTh"; +import { downloadCsv } from "../utils/csv"; + +function friendlyUserAgent(ua: string | null): string { + if (!ua) return "—"; + const os = /Windows/.test(ua) + ? "Windows" + : /Mac OS X/.test(ua) + ? "macOS" + : /Android/.test(ua) + ? "Android" + : /iPhone|iPad/.test(ua) + ? "iOS" + : /Linux/.test(ua) + ? "Linux" + : "unknown OS"; + const browser = /Edg\//.test(ua) + ? "Edge" + : /Chrome\//.test(ua) + ? "Chrome" + : /Firefox\//.test(ua) + ? "Firefox" + : /Safari\//.test(ua) + ? "Safari" + : "unknown browser"; + return `${browser} on ${os}`; +} + +export default function Sessions() { + const [sessions, setSessions] = useState(null); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [error, setError] = useState(null); + const [revokingId, setRevokingId] = useState(null); + + function load() { + api.sessions + .list() + .then((res) => { + setSessions(res.sessions); + setCurrentSessionId(res.currentSessionId); + }) + .catch((err) => setError(String(err))); + } + + useEffect(load, []); + + async function revoke(s: SessionSummary) { + const isSelf = s.id === currentSessionId; + const label = s.name ?? s.email ?? s.sub; + const confirmMsg = isSelf + ? `This is your current session — revoking it will sign you out immediately. Continue?` + : `Revoke ${label}'s session? They'll be signed out immediately.`; + if (!confirm(confirmMsg)) return; + setError(null); + setRevokingId(s.id); + try { + await api.sessions.revoke(s.id); + if (isSelf) { + window.location.href = "/"; + return; + } + load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRevokingId(null); + } + } + + const { sorted, sortKey, sortDir, requestSort } = useSortable(sessions); + + function exportCsv() { + if (!sorted) return; + downloadCsv( + "sessions.csv", + ["Name", "Email", "Role", "IP", "Device", "Last active", "Expires"], + sorted.map((s) => [ + s.name ?? "", + s.email ?? "", + s.role ?? "", + s.ip ?? "", + friendlyUserAgent(s.userAgent), + formatDateTime(new Date(s.lastAccess)), + s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "", + ]), + ); + } + + return ( + <> +
+
+

Sessions

+
Everyone currently signed in, across every device.
+
+ +
+ {error &&
{error}
} +
+
+ + + + label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Email" sortKeyName="email" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Role" sortKeyName="role" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="IP" sortKeyName="ip" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + + label="Last active" + sortKeyName="lastAccess" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + label="Expires" + sortKeyName="expiresAt" + activeKey={sortKey} + direction={sortDir} + onSort={requestSort} + /> + + + + + {sorted?.map((s) => ( + + + + + + + + + + + ))} + {sorted?.length === 0 && ( + + + + )} + +
DeviceActions
+ {s.name ?? "—"} + {s.id === currentSessionId && This device} + {s.email ?? "—"}{s.role ?? unknown user}{s.ip ?? "—"}{friendlyUserAgent(s.userAgent)}{formatDateTime(new Date(s.lastAccess))}{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"} + +
+ No active sessions. +
+
+
+ + ); +}