feat(auth): user-management audit log (table + record sites + /api/audit endpoint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Sonnet 4.6 committed 2026-05-27 15:04:19 +08:00
1 parent ceb24595e6
commit 6af0e97f51
11 files changed
+256 -8

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createAuditStore, type AuditStore } from "./audit.js";
describe("AuditStore", () => {
let botDb: BotDatabase;
let audit: AuditStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
audit = createAuditStore(botDb.db);
});
afterEach(() => botDb.close());
it("records and lists entries newest-first", async () => {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.created",
});
await new Promise((r) => setTimeout(r, 5));
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.deleted",
});
const list = audit.list(10, 0);
expect(list).toHaveLength(2);
expect(list[0].action).toBe("user.deleted");
expect(list[1].action).toBe("user.created");
});
it("supports limit and offset", () => {
for (let i = 0; i < 5; i++) {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: null, targetUsername: null,
action: "user.password_changed",
});
}
expect(audit.list(2, 0)).toHaveLength(2);
expect(audit.list(2, 4)).toHaveLength(1);
expect(audit.list(10, 10)).toHaveLength(0);
});
it("stores nullable fields correctly", () => {
audit.record({
actorId: null, actorUsername: null,
targetUserId: "x", targetUsername: "deleted-user",
action: "admin.first_created",
});
const e = audit.list(1, 0)[0];
expect(e.actorId).toBeNull();
expect(e.actorUsername).toBeNull();
expect(e.targetUserId).toBe("x");
});
});
+56
View File
@@ -0,0 +1,56 @@
import type Database from "better-sqlite3";
export type AuditAction =
| "admin.first_created"
| "user.created"
| "user.deleted"
| "user.password_reset"
| "user.password_changed";
export interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditRecordInput {
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditStore {
record(input: AuditRecordInput): void;
list(limit: number, offset: number): AuditEntry[];
}
export function createAuditStore(db: Database.Database): AuditStore {
const insertStmt = db.prepare(
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
);
const listStmt = db.prepare(
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
);
return {
record(input) {
insertStmt.run(
Date.now(),
input.actorId,
input.actorUsername,
input.targetUserId,
input.targetUsername,
input.action
);
},
list(limit, offset) {
return listStmt.all(limit, offset) as AuditEntry[];
},
};
}
+7
View File
@@ -40,6 +40,13 @@ describe("database", () => {
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]); expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
}); });
it("creates user_audit table on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toContain("user_audit");
});
it("records and retrieves play history", () => { it("records and retrieves play history", () => {
botDb.addPlayHistory({ botDb.addPlayHistory({
botId: "bot1", botId: "bot1",
+11
View File
@@ -147,6 +147,17 @@ function initTables(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
actorId TEXT,
actorUsername TEXT,
targetUserId TEXT,
targetUsername TEXT,
action TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
`); `);
} }
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("audit router", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) {
audit.record({
actorId: alice.id, actorUsername: "alice",
targetUserId: "x", targetUsername: "x",
action: "user.created",
});
}
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions));
app.use("/api/audit", createAuditRouter(audit));
});
afterEach(() => botDb.close());
it("requires auth", async () => {
const res = await request(app).get("/api/audit");
expect(res.status).toBe(401);
});
it("returns entries newest-first", async () => {
const res = await request(app).get("/api/audit").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.entries).toHaveLength(3);
});
it("honors limit query param", async () => {
const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie);
expect(res.body.entries).toHaveLength(1);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { Router } from "express";
import type { AuditStore } from "../../data/audit.js";
export function createAuditRouter(audit: AuditStore): Router {
const router = Router();
router.get("/", (req, res) => {
const limit = clampInt(req.query.limit, 1, 500, 100);
const offset = clampInt(req.query.offset, 0, 100_000, 0);
res.json({ entries: audit.list(limit, offset) });
});
return router;
}
function clampInt(v: unknown, min: number, max: number, def: number): number {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (!Number.isFinite(n)) return def;
return Math.min(Math.max(n, min), max);
}
+5 -3
View File
@@ -6,14 +6,16 @@ import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js"; import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createSessionRouter } from "./session.js"; import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(users: UserStore, sessions: SessionStore) { function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" }))); const audit = createAuditStore(botDb.db);
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
return app; return app;
} }
@@ -35,7 +37,7 @@ describe("session router", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
users = createUserStore(botDb.db); users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db); sessions = createSessionStore(botDb.db);
app = makeApp(users, sessions); app = makeApp(botDb, users, sessions);
}); });
afterEach(() => botDb.close()); afterEach(() => botDb.close());
+12
View File
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js"; import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { SESSION_TTL_MS } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js";
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
@@ -47,6 +48,7 @@ function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
export function createSessionRouter( export function createSessionRouter(
users: UserStore, users: UserStore,
sessions: SessionStore, sessions: SessionStore,
audit: AuditStore,
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
@@ -86,6 +88,11 @@ export function createSessionRouter(
} }
const { token } = sessions.createSession(user.id); const { token } = sessions.createSession(user.id);
setSessionCookie(res, token); setSessionCookie(res, token);
audit.record({
actorId: user.id, actorUsername: user.username,
targetUserId: user.id, targetUsername: user.username,
action: "admin.first_created",
});
logger.info({ userId: user.id, username }, "First admin created"); logger.info({ userId: user.id, username }, "First admin created");
res.json({ id: user.id, username: user.username }); res.json({ id: user.id, username: user.username });
} catch (err) { } catch (err) {
@@ -144,6 +151,11 @@ export function createSessionRouter(
await users.changePassword(u.id, newPassword); await users.changePassword(u.id, newPassword);
const currentToken = parseTokenFromCookie(req.headers.cookie); const currentToken = parseTokenFromCookie(req.headers.cookie);
sessions.deleteAllForUser(u.id, currentToken ?? undefined); sessions.deleteAllForUser(u.id, currentToken ?? undefined);
audit.record({
actorId: u.id, actorUsername: u.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.password_changed",
});
res.status(204).end(); res.status(204).end();
}); });
+5 -3
View File
@@ -6,17 +6,19 @@ import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js"; import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js"; import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(users: UserStore, sessions: SessionStore) { function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
const requireAuth = createRequireAuth(sessions); const requireAuth = createRequireAuth(sessions);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth); app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, pino({ level: "silent" }))); app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" })));
return app; return app;
} }
@@ -33,7 +35,7 @@ describe("users router", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
users = createUserStore(botDb.db); users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db); sessions = createSessionStore(botDb.db);
app = makeApp(users, sessions); app = makeApp(botDb, users, sessions);
const alice = await users.createUser("alice", "pw-alice"); const alice = await users.createUser("alice", "pw-alice");
aliceId = alice.id; aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
+22
View File
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js"; import type { UserStore } from "../../data/users.js";
import { UsernameTakenError } from "../../data/users.js"; import { UsernameTakenError } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
function isValidUsername(v: unknown): v is string { function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
@@ -15,6 +16,7 @@ function isValidPassword(v: unknown): v is string {
export function createUsersRouter( export function createUsersRouter(
users: UserStore, users: UserStore,
sessions: SessionStore, sessions: SessionStore,
audit: AuditStore,
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
@@ -31,6 +33,11 @@ export function createUsersRouter(
} }
try { try {
const u = await users.createUser(username, password); const u = await users.createUser(username, password);
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.created",
});
logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created"); logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created");
res.status(201).json({ id: u.id, username: u.username }); res.status(201).json({ id: u.id, username: u.username });
} catch (err) { } catch (err) {
@@ -45,6 +52,11 @@ export function createUsersRouter(
router.delete("/:id", (req, res) => { router.delete("/:id", (req, res) => {
const targetId = req.params.id; const targetId = req.params.id;
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
if (targetId === req.user!.id) { if (targetId === req.user!.id) {
res.status(400).json({ error: "cannot delete self" }); res.status(400).json({ error: "cannot delete self" });
return; return;
@@ -55,6 +67,11 @@ export function createUsersRouter(
return; return;
} }
sessions.deleteAllForUser(targetId); sessions.deleteAllForUser(targetId);
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.deleted",
});
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted"); logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
res.status(204).end(); res.status(204).end();
}); });
@@ -75,6 +92,11 @@ export function createUsersRouter(
// Invalidate all sessions for the target user (except current actor's if it's the same user) // Invalidate all sessions for the target user (except current actor's if it's the same user)
const exceptToken = targetId === req.user!.id ? undefined : undefined; const exceptToken = targetId === req.user!.id ? undefined : undefined;
sessions.deleteAllForUser(targetId, exceptToken); sessions.deleteAllForUser(targetId, exceptToken);
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.password_reset",
});
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset"); logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
res.status(204).end(); res.status(204).end();
}); });
+6 -2
View File
@@ -16,6 +16,8 @@ import { createPlayerRouter } from "./api/player.js";
import { createAuthRouter } from "./api/auth.js"; import { createAuthRouter } from "./api/auth.js";
import { createSessionRouter } from "./api/session.js"; import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js"; import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { setupWebSocket } from "./websocket.js"; import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
@@ -59,6 +61,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
const users = createUserStore(options.database.db); const users = createUserStore(options.database.db);
const sessions = createSessionStore(options.database.db); const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ─────────────────────────────────── // ─── Public routes (no auth, no CSRF) ───────────────────────────────────
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
@@ -70,7 +73,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
}); });
app.use("/api/session", createSessionRouter(users, sessions, logger)); app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
// ─── Gates for everything else under /api ─────────────────────────────── // ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions); const requireAuth = createRequireAuth(sessions);
@@ -101,7 +104,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
); );
app.use("/api/users", createUsersRouter(users, sessions, logger)); app.use("/api/users", createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", createAuditRouter(audit));
// ─── Static SPA (public) ──────────────────────────────────────────────── // ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) { if (options.staticDir) {