mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +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,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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user