mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
ceb24595e6
commit
6af0e97f51
11 files changed
+256
-8
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,13 @@ describe("database", () => {
|
||||
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", () => {
|
||||
botDb.addPlayHistory({
|
||||
botId: "bot1",
|
||||
|
||||
@@ -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_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);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,14 +6,16 @@ import pino from "pino";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createSessionRouter } from "./session.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();
|
||||
app.use(express.json());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ describe("session router", () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
app = makeApp(users, sessions);
|
||||
app = makeApp(botDb, users, sessions);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { UserStore } from "../../data/users.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_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
|
||||
|
||||
@@ -47,6 +48,7 @@ function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
|
||||
export function createSessionRouter(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
@@ -86,6 +88,11 @@ export function createSessionRouter(
|
||||
}
|
||||
const { token } = sessions.createSession(user.id);
|
||||
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");
|
||||
res.json({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
@@ -144,6 +151,11 @@ export function createSessionRouter(
|
||||
await users.changePassword(u.id, newPassword);
|
||||
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,17 +6,19 @@ import pino from "pino";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createUsersRouter } from "./users.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();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
const requireAuth = createRequireAuth(sessions);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,7 +35,7 @@ describe("users router", () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
app = makeApp(users, sessions);
|
||||
app = makeApp(botDb, users, sessions);
|
||||
const alice = await users.createUser("alice", "pw-alice");
|
||||
aliceId = alice.id;
|
||||
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
|
||||
import type { UserStore } from "../../data/users.js";
|
||||
import { UsernameTakenError } from "../../data/users.js";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
|
||||
function isValidUsername(v: unknown): v is string {
|
||||
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(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
@@ -31,6 +33,11 @@ export function createUsersRouter(
|
||||
}
|
||||
try {
|
||||
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");
|
||||
res.status(201).json({ id: u.id, username: u.username });
|
||||
} catch (err) {
|
||||
@@ -45,6 +52,11 @@ export function createUsersRouter(
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
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) {
|
||||
res.status(400).json({ error: "cannot delete self" });
|
||||
return;
|
||||
@@ -55,6 +67,11 @@ export function createUsersRouter(
|
||||
return;
|
||||
}
|
||||
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");
|
||||
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)
|
||||
const exceptToken = targetId === req.user!.id ? undefined : undefined;
|
||||
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");
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
+6
-2
@@ -16,6 +16,8 @@ import { createPlayerRouter } from "./api/player.js";
|
||||
import { createAuthRouter } from "./api/auth.js";
|
||||
import { createSessionRouter } from "./api/session.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 { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
@@ -59,6 +61,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
|
||||
const users = createUserStore(options.database.db);
|
||||
const sessions = createSessionStore(options.database.db);
|
||||
const audit = createAuditStore(options.database.db);
|
||||
|
||||
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||
app.get("/api/health", (_req, res) => {
|
||||
@@ -70,7 +73,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
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 ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions);
|
||||
@@ -101,7 +104,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
"/api/auth",
|
||||
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) ────────────────────────────────────────────────
|
||||
if (options.staticDir) {
|
||||
|
||||
Reference in new issue
Block a user