Add session management: see who's signed in, revoke a session
No visibility existed into who was currently signed in or a way to force a device out. Sessions already live as files via session-file-store, so this reads that store directly rather than adding a new DB table: new Settings-adjacent "Sessions" page (admin-only, alongside Users) lists every live session with the user's name/email/resolved role, IP, a friendly "Browser on OS" summary parsed from the user-agent, last-active time, and expiry, with a Revoke button per row (extra confirmation if you revoke your own current session, since that signs you out immediately). IP and user-agent are now captured into the session at login (auth/router.ts) since express-session doesn't track them itself. session-file-store's own Store type doesn't declare its list() method, so sessionStore.ts adds a narrow local interface for it rather than losing type safety on the rest of the store. Verified against a real session directory seeded through the actual session-file-store APIs (not hand-written JSON): confirmed correct field resolution including a session whose user row was later deleted (role resolves to null instead of crashing), correctly excluded a mid-OIDC-login session with no completed user yet, correctly excluded an already-expired session, and confirmed revoke actually deletes the right session file and only that one. Confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cae35a59e
commit
6d673db9ec
9 files changed
+318
-1
No files matched your search
@@ -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("/");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
store.list((err, files) => (err ? reject(err) : resolve(files ?? [])));
|
||||
});
|
||||
}
|
||||
|
||||
function getSession(sessionId: string): Promise<session.SessionData | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
store.get(sessionId, (err, sess) => (err ? reject(err) : resolve(sess ?? null)));
|
||||
});
|
||||
}
|
||||
|
||||
export function destroySession(sessionId: string): Promise<void> {
|
||||
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<SessionSummary[]> {
|
||||
const files = await listFiles();
|
||||
const roleBySub = new Map<string, UserRole>();
|
||||
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));
|
||||
}
|
||||
+2
@@ -7,6 +7,8 @@ declare module "express-session" {
|
||||
email?: string;
|
||||
name?: string;
|
||||
idToken?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
pendingAuth?: {
|
||||
codeVerifier: string;
|
||||
|
||||
@@ -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() {
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sessions"
|
||||
element={
|
||||
<RequireRole user={user} minRole="admin">
|
||||
<Sessions />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-log"
|
||||
element={
|
||||
|
||||
@@ -18,6 +18,18 @@ export interface UserRecord {
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
sub: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: UserRole | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
lastAccess: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
actorUserId: number | null;
|
||||
@@ -587,6 +599,10 @@ export const api = {
|
||||
body: JSON.stringify({ role }),
|
||||
}),
|
||||
},
|
||||
sessions: {
|
||||
list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"),
|
||||
revoke: (id: string) => request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
},
|
||||
auditLog: {
|
||||
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
|
||||
},
|
||||
|
||||
@@ -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: <IconBrandGit size={20} /> },
|
||||
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
|
||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||
{ to: "/diag-log", label: "Diagnostic Log", icon: <IconStethoscope size={20} />, minRole: "admin" },
|
||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||
|
||||
@@ -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<SessionSummary[] | null>(null);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(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 (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
<h2 className="page-title mb-0">Sessions</h2>
|
||||
<div className="text-secondary small mt-1">Everyone currently signed in, across every device.</div>
|
||||
</div>
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<SessionSummary> label="Name" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<SessionSummary> label="Email" sortKeyName="email" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<SessionSummary> label="Role" sortKeyName="role" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<SessionSummary> label="IP" sortKeyName="ip" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<th>Device</th>
|
||||
<SortableTh<SessionSummary>
|
||||
label="Last active"
|
||||
sortKeyName="lastAccess"
|
||||
activeKey={sortKey}
|
||||
direction={sortDir}
|
||||
onSort={requestSort}
|
||||
/>
|
||||
<SortableTh<SessionSummary>
|
||||
label="Expires"
|
||||
sortKeyName="expiresAt"
|
||||
activeKey={sortKey}
|
||||
direction={sortDir}
|
||||
onSort={requestSort}
|
||||
/>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted?.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
{s.name ?? "—"}
|
||||
{s.id === currentSessionId && <span className="badge bg-blue-lt text-blue ms-2">This device</span>}
|
||||
</td>
|
||||
<td>{s.email ?? "—"}</td>
|
||||
<td>{s.role ?? <span className="text-secondary">unknown user</span>}</td>
|
||||
<td>{s.ip ?? "—"}</td>
|
||||
<td className="text-secondary">{friendlyUserAgent(s.userAgent)}</td>
|
||||
<td>{formatDateTime(new Date(s.lastAccess))}</td>
|
||||
<td>{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => revoke(s)}
|
||||
disabled={revokingId === s.id}
|
||||
>
|
||||
{revokingId === s.id ? "Revoking…" : "Revoke"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-secondary text-center">
|
||||
No active sessions.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user