mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
This commit is contained in:
39 files changed
+5943
-52
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,57 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export type AuditAction =
|
||||
| "admin.first_created"
|
||||
| "user.created"
|
||||
| "user.deleted"
|
||||
| "user.password_reset"
|
||||
| "user.password_changed"
|
||||
| "user.role_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[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,30 @@ describe("database", () => {
|
||||
expect(names).toContain("bot_instances");
|
||||
});
|
||||
|
||||
it("creates users and sessions tables on init", () => {
|
||||
const tables = botDb.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = tables.map((t) => t.name);
|
||||
expect(names).toContain("users");
|
||||
expect(names).toContain("sessions");
|
||||
|
||||
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
||||
const userColNames = userCols.map((c) => c.name).sort();
|
||||
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
|
||||
|
||||
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
||||
const sessionColNames = sessionCols.map((c) => c.name).sort();
|
||||
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",
|
||||
|
||||
@@ -97,6 +97,12 @@ function migrateSchema(db: Database.Database): void {
|
||||
if (!names.includes("custom_avatar_path")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
|
||||
}
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
||||
const userColNames = userColumns.map((c) => c.name);
|
||||
if (!userColNames.includes("role")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
|
||||
}
|
||||
}
|
||||
|
||||
function initTables(db: Database.Database): void {
|
||||
@@ -127,12 +133,45 @@ function initTables(db: Database.Database): void {
|
||||
serverPassword TEXT NOT NULL DEFAULT '',
|
||||
identity TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
passwordHash TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
updatedAt INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
lastSeenAt INTEGER NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
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);
|
||||
`);
|
||||
}
|
||||
|
||||
export function createDatabase(dbPath: string): BotDatabase {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initTables(db);
|
||||
migrateSchema(db);
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, type UserStore } from "./users.js";
|
||||
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js";
|
||||
|
||||
function sha256(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
describe("SessionStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
userId = u.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
|
||||
expect(row.id).toBe(sha256(token));
|
||||
expect(row.id).not.toBe(token);
|
||||
});
|
||||
|
||||
it("validateAndTouch returns the user for a fresh token", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe(userId);
|
||||
expect(result!.username).toBe("alice");
|
||||
expect(result!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("validateAndTouch returns null and deletes the row for an expired session", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token } = sessions.createSession(userId);
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token } = sessions.createSession(userId);
|
||||
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
|
||||
sessions.validateAndTouch(token);
|
||||
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||
expect(after.lastSeenAt).toBe(before.lastSeenAt);
|
||||
});
|
||||
|
||||
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
|
||||
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
|
||||
sessions.validateAndTouch(token);
|
||||
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
|
||||
expect(row.lastSeenAt).toBe(Date.now());
|
||||
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
|
||||
});
|
||||
|
||||
it("deleteSession removes the row", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
sessions.deleteSession(token);
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(0);
|
||||
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteAllForUser keeps the exceptToken session", () => {
|
||||
const a = sessions.createSession(userId);
|
||||
const b = sessions.createSession(userId);
|
||||
sessions.deleteAllForUser(userId, a.token);
|
||||
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
|
||||
expect(sessions.validateAndTouch(b.token)).toBeNull();
|
||||
});
|
||||
|
||||
it("cleanupExpired removes only expired rows", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
sessions.createSession(userId); // expires later
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||
sessions.createSession(userId); // fresh
|
||||
sessions.cleanupExpired();
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(1);
|
||||
});
|
||||
|
||||
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
|
||||
// Create MAX + 2 sessions for the same user.
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
|
||||
tokens.push(sessions.createSession(userId).token);
|
||||
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
|
||||
}
|
||||
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||
// The first two should have been evicted, the last MAX remain
|
||||
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
|
||||
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
||||
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
|
||||
});
|
||||
|
||||
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
|
||||
// better-sqlite3 transactions are serialised at the engine level. Calling
|
||||
// createSession N times sequentially via Promise.all proves atomic check+insert.
|
||||
const N = MAX_SESSIONS_PER_USER + 3;
|
||||
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
|
||||
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface SessionValidation {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: "admin" | "member";
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
createSession(userId: string): { token: string; expiresAt: number };
|
||||
validateAndTouch(rawToken: string): SessionValidation | null;
|
||||
deleteSession(rawToken: string): void;
|
||||
deleteAllForUser(userId: string, exceptToken?: string): void;
|
||||
cleanupExpired(): void;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createSessionStore(db: Database.Database): SessionStore {
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const selectStmt = db.prepare(`
|
||||
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
|
||||
FROM sessions s INNER JOIN users u ON u.id = s.userId
|
||||
WHERE s.id = ?
|
||||
`);
|
||||
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
|
||||
const touchStmt = db.prepare(
|
||||
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
|
||||
);
|
||||
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
|
||||
const deleteAllForUserExceptStmt = db.prepare(
|
||||
"DELETE FROM sessions WHERE userId = ? AND id != ?"
|
||||
);
|
||||
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
|
||||
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
|
||||
const deleteOldestForUserStmt = db.prepare(
|
||||
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
|
||||
);
|
||||
|
||||
return {
|
||||
createSession(userId) {
|
||||
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
||||
// Wrap the count → delete → insert in a transaction so concurrent logins
|
||||
// for the same user can't both pass the cap check and both insert,
|
||||
// ending up 1 over cap (race window between count and insert).
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const id = hashToken(token);
|
||||
const now = Date.now();
|
||||
const expiresAt = now + SESSION_TTL_MS;
|
||||
const tx = db.transaction(() => {
|
||||
const existing = (countForUserStmt.get(userId) as { n: number }).n;
|
||||
if (existing >= MAX_SESSIONS_PER_USER) {
|
||||
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
|
||||
}
|
||||
insertStmt.run(id, userId, now, expiresAt, now);
|
||||
});
|
||||
tx();
|
||||
return { token, expiresAt };
|
||||
},
|
||||
|
||||
validateAndTouch(rawToken) {
|
||||
if (!rawToken) return null;
|
||||
const id = hashToken(rawToken);
|
||||
const row = selectStmt.get(id) as
|
||||
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const now = Date.now();
|
||||
if (row.expiresAt < now) {
|
||||
deleteByIdStmt.run(id);
|
||||
return null;
|
||||
}
|
||||
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
|
||||
touchStmt.run(now, now + SESSION_TTL_MS, id);
|
||||
}
|
||||
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" };
|
||||
},
|
||||
|
||||
deleteSession(rawToken) {
|
||||
deleteByIdStmt.run(hashToken(rawToken));
|
||||
},
|
||||
|
||||
deleteAllForUser(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
|
||||
} else {
|
||||
deleteAllForUserStmt.run(userId);
|
||||
}
|
||||
},
|
||||
|
||||
cleanupExpired() {
|
||||
cleanupStmt.run(Date.now());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js";
|
||||
|
||||
describe("UserStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("countUsers is 0 on a fresh db", () => {
|
||||
expect(users.countUsers()).toBe(0);
|
||||
});
|
||||
|
||||
it("createUser stores the user and bumps countUsers", async () => {
|
||||
const u = await users.createUser("alice", "pw-hunter2", "member");
|
||||
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(u.username).toBe("alice");
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("findByUsername is case-insensitive and returns null for missing", async () => {
|
||||
await users.createUser("Alice", "pw-alice", "member");
|
||||
expect(users.findByUsername("ALICE")).not.toBeNull();
|
||||
expect(users.findByUsername("alice")).not.toBeNull();
|
||||
expect(users.findByUsername("bob")).toBeNull();
|
||||
});
|
||||
|
||||
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
|
||||
await users.createUser("Alice", "pw-alice", "member");
|
||||
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
|
||||
});
|
||||
|
||||
it("verifyPassword accepts correct password and rejects wrong one", async () => {
|
||||
await users.createUser("alice", "correct-horse-battery-staple", "member");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(row).not.toBeNull();
|
||||
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
|
||||
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
|
||||
});
|
||||
|
||||
it("changePassword updates the hash so the old password no longer verifies", async () => {
|
||||
const u = await users.createUser("alice", "old-pw-pw", "member");
|
||||
await users.changePassword(u.id, "new-pw-pw");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
|
||||
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
|
||||
await users.createUser("alice", "pw-alice", "member");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
const list = users.listUsers();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].username).toBe("alice");
|
||||
expect(list[1].username).toBe("bob");
|
||||
expect(list[0]).not.toHaveProperty("passwordHash");
|
||||
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(typeof list[0].createdAt).toBe("number");
|
||||
});
|
||||
|
||||
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
|
||||
const u = await users.createUser("alice", "pw-alice", "member");
|
||||
expect(users.deleteUser(u.id)).toBe(true);
|
||||
expect(users.countUsers()).toBe(0);
|
||||
expect(users.deleteUser("not-a-real-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
|
||||
const a = await users.createFirstUser("alice", "pw-alice");
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.username).toBe("alice");
|
||||
const b = await users.createFirstUser("bob", "pw-bob-bob");
|
||||
expect(b).toBeNull();
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
|
||||
const [a, b, c] = await Promise.all([
|
||||
users.createFirstUser("alice", "pw-alice"),
|
||||
users.createFirstUser("bob", "pw-bob-bob"),
|
||||
users.createFirstUser("charlie", "pw-charlie-pw"),
|
||||
]);
|
||||
const created = [a, b, c].filter((u) => u !== null);
|
||||
expect(created).toHaveLength(1);
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("createFirstUser always creates an admin", async () => {
|
||||
const u = await users.createFirstUser("alice", "pw-alice");
|
||||
expect(u).not.toBeNull();
|
||||
expect(u!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("countAdmins reflects only role=admin", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
expect(users.countUsers()).toBe(2);
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
|
||||
it("setRole changes the role and returns true; false for unknown id", async () => {
|
||||
const u = await users.createUser("alice", "pw-alice", "member");
|
||||
expect(users.setRole(u.id, "admin")).toBe(true);
|
||||
expect(users.findById(u.id)!.role).toBe("admin");
|
||||
expect(users.setRole("nope", "admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("listUsers includes role", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
const list = users.listUsers();
|
||||
const alice = list.find((u) => u.username === "alice")!;
|
||||
const bob = list.find((u) => u.username === "bob")!;
|
||||
expect(alice.role).toBe("admin");
|
||||
expect(bob.role).toBe("member");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
|
||||
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
|
||||
expect(users.findById(alice.id)!.role).toBe("member");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
|
||||
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
// Concurrent demotion of both
|
||||
const [r1, r2] = await Promise.all([
|
||||
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
|
||||
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
|
||||
]);
|
||||
// Exactly one should succeed; the other gets "would_orphan"
|
||||
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||
expect(oks).toBe(1);
|
||||
expect(orphans).toBe(1);
|
||||
// System retains at least one admin
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
|
||||
expect(users.findById(alice.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
||||
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
|
||||
expect(users.findById(bob.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
const [r1, r2] = await Promise.all([
|
||||
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
|
||||
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
|
||||
]);
|
||||
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||
expect(oks).toBe(1);
|
||||
expect(orphans).toBe(1);
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
export type UserRole = "admin" | "member";
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UserStore {
|
||||
countUsers(): number;
|
||||
countAdmins(): number;
|
||||
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
|
||||
createFirstUser(username: string, password: string): Promise<UserRow | null>;
|
||||
findByUsername(username: string): UserRow | null;
|
||||
findById(id: string): UserRow | null;
|
||||
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
||||
changePassword(userId: string, newPassword: string): Promise<void>;
|
||||
setRole(userId: string, role: UserRole): boolean;
|
||||
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
|
||||
deleteUser(id: string): boolean;
|
||||
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
|
||||
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||
}
|
||||
|
||||
export class UsernameTakenError extends Error {
|
||||
constructor(username: string) {
|
||||
super(`username taken: ${username}`);
|
||||
this.name = "UsernameTakenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createUserStore(db: Database.Database): UserStore {
|
||||
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users");
|
||||
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const findByUsernameStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
|
||||
);
|
||||
const findByIdStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
|
||||
);
|
||||
const updatePasswordStmt = db.prepare(
|
||||
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
const updateRoleStmt = db.prepare(
|
||||
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
const listUsersStmt = db.prepare(
|
||||
"SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC"
|
||||
);
|
||||
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
||||
|
||||
return {
|
||||
countUsers() {
|
||||
return (countStmt.get() as { n: number }).n;
|
||||
},
|
||||
|
||||
countAdmins() {
|
||||
return (countAdminsStmt.get() as { n: number }).n;
|
||||
},
|
||||
|
||||
async createUser(username, password, role) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
try {
|
||||
insertStmt.run(id, username, hash, now, now, role);
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
throw new UsernameTakenError(username);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
|
||||
},
|
||||
|
||||
async createFirstUser(username, password) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
const run = db.transaction(() => {
|
||||
const count = (countStmt.get() as { n: number }).n;
|
||||
if (count !== 0) return null;
|
||||
try {
|
||||
insertStmt.run(id, username, hash, now, now, "admin");
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
|
||||
});
|
||||
return run();
|
||||
},
|
||||
|
||||
findByUsername(username) {
|
||||
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
|
||||
},
|
||||
|
||||
verifyPassword(plain, hash) {
|
||||
return bcrypt.compare(plain, hash);
|
||||
},
|
||||
|
||||
async changePassword(userId, newPassword) {
|
||||
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
updatePasswordStmt.run(hash, Date.now(), userId);
|
||||
},
|
||||
|
||||
setRole(userId, role) {
|
||||
const result = updateRoleStmt.run(role, Date.now(), userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
setRoleIfNotLastAdmin(id, newRole) {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === newRole) return "ok" as const; // no-op
|
||||
if (row.role === "admin" && newRole === "member") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
if (adminCount <= 1) return "would_orphan" as const;
|
||||
}
|
||||
updateRoleStmt.run(newRole, Date.now(), id);
|
||||
return "ok" as const;
|
||||
});
|
||||
return tx();
|
||||
},
|
||||
|
||||
listUsers() {
|
||||
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||
},
|
||||
|
||||
deleteUser(id) {
|
||||
const result = deleteUserStmt.run(id);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
deleteUserIfNotLastAdmin(id) {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === "admin") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
if (adminCount <= 1) return "would_orphan" as const;
|
||||
}
|
||||
deleteUserStmt.run(id);
|
||||
return "ok" as const;
|
||||
});
|
||||
return tx();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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", "admin");
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
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(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
const audit = createAuditStore(botDb.db);
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
|
||||
return app;
|
||||
}
|
||||
|
||||
function extractCookie(res: request.Response): string {
|
||||
const header = res.headers["set-cookie"];
|
||||
const arr = Array.isArray(header) ? header : header ? [header] : [];
|
||||
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||
if (!found) throw new Error("no session cookie set");
|
||||
return found.split(";")[0]; // "tsmb_session=xxxx"
|
||||
}
|
||||
|
||||
describe("session router", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
app = makeApp(botDb, users, sessions);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("GET /needs-setup returns true on an empty db", async () => {
|
||||
const res = await request(app).get("/api/session/needs-setup");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsSetup: true });
|
||||
});
|
||||
|
||||
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
|
||||
const setupRes = await request(app)
|
||||
.post("/api/session/setup")
|
||||
.send({ username: "alice", password: "hunter2-hunter2" });
|
||||
expect(setupRes.status).toBe(200);
|
||||
expect(setupRes.body.username).toBe("alice");
|
||||
extractCookie(setupRes);
|
||||
|
||||
const needs = await request(app).get("/api/session/needs-setup");
|
||||
expect(needs.body).toEqual({ needsSetup: false });
|
||||
});
|
||||
|
||||
it("POST /setup returns 409 once a user already exists", async () => {
|
||||
await users.createUser("admin", "pw-admin-pw", "admin");
|
||||
const res = await request(app)
|
||||
.post("/api/session/setup")
|
||||
.send({ username: "alice", password: "pw" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: "already initialized" });
|
||||
});
|
||||
|
||||
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
|
||||
await users.createUser("alice", "correct-pw-pw", "admin");
|
||||
const start = Date.now();
|
||||
const res = await request(app)
|
||||
.post("/api/session/login")
|
||||
.send({ username: "alice", password: "wrong" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "invalid credentials" });
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
|
||||
}, 10_000);
|
||||
|
||||
it("POST /login sets a session cookie on success", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
const res = await request(app)
|
||||
.post("/api/session/login")
|
||||
.send({ username: "alice", password: "pw-alice" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.username).toBe("alice");
|
||||
extractCookie(res);
|
||||
});
|
||||
|
||||
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
const loginRes = await request(app)
|
||||
.post("/api/session/login")
|
||||
.send({ username: "alice", password: "pw-alice" });
|
||||
const cookie = extractCookie(loginRes);
|
||||
|
||||
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.username).toBe("alice");
|
||||
|
||||
const anon = await request(app).get("/api/session/me");
|
||||
expect(anon.status).toBe(401);
|
||||
});
|
||||
|
||||
it("POST /logout deletes the session and clears the cookie", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
const loginRes = await request(app)
|
||||
.post("/api/session/login")
|
||||
.send({ username: "alice", password: "pw-alice" });
|
||||
const cookie = extractCookie(loginRes);
|
||||
|
||||
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
|
||||
expect(logout.status).toBe(204);
|
||||
|
||||
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||
expect(me.status).toBe(401);
|
||||
});
|
||||
|
||||
it("POST /change-password requires old password and invalidates other sessions", async () => {
|
||||
const u = await users.createUser("alice", "old-pw-pw", "admin");
|
||||
const cookieA = extractCookie(
|
||||
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
|
||||
);
|
||||
const cookieB = extractCookie(
|
||||
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
|
||||
);
|
||||
|
||||
const wrongOld = await request(app)
|
||||
.post("/api/session/change-password")
|
||||
.set("Cookie", cookieA)
|
||||
.send({ oldPassword: "WRONG", newPassword: "newpassword" });
|
||||
expect(wrongOld.status).toBe(401);
|
||||
|
||||
const ok = await request(app)
|
||||
.post("/api/session/change-password")
|
||||
.set("Cookie", cookieA)
|
||||
.send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
|
||||
expect(ok.status).toBe(204);
|
||||
|
||||
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
|
||||
expect(meA.status).toBe(200);
|
||||
|
||||
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
|
||||
expect(meB.status).toBe(401);
|
||||
|
||||
expect(u.id).toBe(meA.body.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Router } from "express";
|
||||
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";
|
||||
|
||||
const FAILED_LOGIN_DELAY_MS = 250;
|
||||
|
||||
function setSessionCookie(res: Response, token: string): void {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: res.req.secure,
|
||||
path: "/",
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: Response): void {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isValidUsername(v: unknown): v is string {
|
||||
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
||||
}
|
||||
|
||||
function isValidPassword(v: unknown): v is string {
|
||||
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
||||
}
|
||||
|
||||
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
|
||||
}
|
||||
|
||||
export function createSessionRouter(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
clearSessionCookie(res);
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = { id: result.userId, username: result.username, role: result.role };
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) setSessionCookie(res, token);
|
||||
next();
|
||||
};
|
||||
|
||||
router.get("/needs-setup", (_req, res) => {
|
||||
res.json({ needsSetup: users.countUsers() === 0 });
|
||||
});
|
||||
|
||||
router.post("/setup", async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (users.countUsers() !== 0) {
|
||||
res.status(409).json({ error: "already initialized" });
|
||||
return;
|
||||
}
|
||||
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||
res.status(400).json({ error: "invalid username or password" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const user = await users.createFirstUser(username, password);
|
||||
if (!user) {
|
||||
res.status(409).json({ error: "already initialized" });
|
||||
return;
|
||||
}
|
||||
const { token } = sessions.createSession(user.id);
|
||||
setSessionCookie(res, token);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: user.id, actorUsername: user.username,
|
||||
targetUserId: user.id, targetUsername: user.username,
|
||||
action: "admin.first_created",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ userId: user.id, username }, "First admin created");
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "setup failed");
|
||||
res.status(500).json({ error: "internal" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
res.status(400).json({ error: "invalid request" });
|
||||
return;
|
||||
}
|
||||
const user = users.findByUsername(username);
|
||||
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
|
||||
if (!user || !ok) {
|
||||
await delay(FAILED_LOGIN_DELAY_MS);
|
||||
res.status(401).json({ error: "invalid credentials" });
|
||||
return;
|
||||
}
|
||||
const { token } = sessions.createSession(user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
const token = parseTokenFromCookie(req.headers.cookie);
|
||||
if (token) {
|
||||
sessions.deleteSession(token);
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.get("/me", requireAuthInline, (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
router.post("/change-password", requireAuthInline, async (req, res) => {
|
||||
const { oldPassword, newPassword } = req.body ?? {};
|
||||
if (typeof oldPassword !== "string") {
|
||||
res.status(400).json({ error: "invalid request" });
|
||||
return;
|
||||
}
|
||||
const u = users.findById(req.user!.id);
|
||||
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
|
||||
await delay(FAILED_LOGIN_DELAY_MS);
|
||||
res.status(401).json({ error: "invalid credentials" });
|
||||
return;
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
res.status(400).json({ error: "invalid request" });
|
||||
return;
|
||||
}
|
||||
await users.changePassword(u.id, newPassword);
|
||||
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
||||
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: u.id, actorUsername: u.username,
|
||||
targetUserId: u.id, targetUsername: u.username,
|
||||
action: "user.password_changed",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed");
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
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(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, audit, pino({ level: "silent" })));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("users router", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let app: express.Express;
|
||||
let aliceId: string;
|
||||
let aliceCookie: string;
|
||||
let bobId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
app = makeApp(botDb, users, sessions);
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
aliceId = alice.id;
|
||||
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
||||
bobId = bob.id;
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("requires auth for all routes", async () => {
|
||||
expect((await request(app).get("/api/users")).status).toBe(401);
|
||||
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
|
||||
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it("GET / lists users with id+username+createdAt, no password hash", async () => {
|
||||
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.users).toHaveLength(2);
|
||||
for (const u of res.body.users) {
|
||||
expect(u).toHaveProperty("id");
|
||||
expect(u).toHaveProperty("username");
|
||||
expect(u).toHaveProperty("createdAt");
|
||||
expect(u).not.toHaveProperty("passwordHash");
|
||||
}
|
||||
});
|
||||
|
||||
it("POST / creates a user", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "charlie", password: "charlie-pw" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.username).toBe("charlie");
|
||||
expect(users.countUsers()).toBe(3);
|
||||
});
|
||||
|
||||
it("POST / returns 409 on duplicate username", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "BOB", password: "another-pw" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("POST / returns 400 on invalid input", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "x", password: "short" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("DELETE /:id removes the user and their sessions", async () => {
|
||||
const bobToken = sessions.createSession(bobId).token;
|
||||
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(204);
|
||||
expect(users.countUsers()).toBe(1);
|
||||
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||
});
|
||||
|
||||
it("DELETE /:id of self returns 400", async () => {
|
||||
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "cannot delete self" });
|
||||
expect(users.countUsers()).toBe(2);
|
||||
});
|
||||
|
||||
it("DELETE /:id of nonexistent returns 404", async () => {
|
||||
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
|
||||
const bobToken = sessions.createSession(bobId).token;
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${bobId}/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "bob-new-pw" });
|
||||
expect(res.status).toBe(204);
|
||||
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||
const bob = users.findByUsername("bob");
|
||||
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
|
||||
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password 404 on unknown user", async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/users/not-a-real-id/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "anything-here" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password 400 on short password", async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${bobId}/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "short" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
|
||||
// Build a broken audit store that throws on record()
|
||||
const brokenAudit = {
|
||||
record: () => { throw new Error("simulated disk-full"); },
|
||||
list: () => [],
|
||||
};
|
||||
// Reassemble app with the broken audit
|
||||
const localApp = express();
|
||||
localApp.use(express.json());
|
||||
localApp.use(cookieParser());
|
||||
localApp.use("/api", createRequireAuth(sessions));
|
||||
localApp.use(
|
||||
"/api/users",
|
||||
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }))
|
||||
);
|
||||
const res = await request(localApp)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "charlie", password: "charlie-pw" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(users.countUsers()).toBe(3);
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
|
||||
// Alice resets her OWN password
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${aliceId}/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "alice-new-pw" });
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// Alice's CURRENT session should still work
|
||||
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
|
||||
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
||||
expect(followUp.status).toBe(200);
|
||||
|
||||
// The password hash IS updated (sanity check)
|
||||
const alice = users.findById(aliceId);
|
||||
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
|
||||
const bobToken = sessions.createSession(bobId).token;
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${bobId}/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "bob-new-pw" });
|
||||
expect(res.status).toBe(204);
|
||||
// Bob's session should be dead
|
||||
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||
});
|
||||
|
||||
it("POST / defaults new user to role=member when role omitted", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "carol", password: "pw-carol-pw" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe("member");
|
||||
});
|
||||
|
||||
it("POST / accepts role=admin", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/users")
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe("admin");
|
||||
expect(users.countAdmins()).toBe(2);
|
||||
});
|
||||
|
||||
it("PATCH /:id/role can change role between admin and member", async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/${bobId}/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "admin" });
|
||||
expect(res.status).toBe(204);
|
||||
expect(users.findById(bobId)!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("PATCH /:id/role blocks demoting the last admin", async () => {
|
||||
// alice is the only admin. Demoting her would leave 0 admins. Block.
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/${aliceId}/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "member" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "cannot demote last admin" });
|
||||
});
|
||||
|
||||
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
|
||||
// Promote bob first
|
||||
users.setRole(bobId, "admin");
|
||||
// Now both are admins. Demoting alice should work.
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/${aliceId}/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "member" });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("PATCH /:id/role 400 on invalid role", async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/${bobId}/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "superuser" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PATCH /:id/role 404 on unknown user", async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/not-a-real-id/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "admin" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Router } from "express";
|
||||
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";
|
||||
import { extractSessionToken } from "../auth/validateSession.js";
|
||||
|
||||
function isValidUsername(v: unknown): v is string {
|
||||
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
||||
}
|
||||
|
||||
function isValidPassword(v: unknown): v is string {
|
||||
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
||||
}
|
||||
|
||||
export function createUsersRouter(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ users: users.listUsers() });
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
const { username, password, role: roleInput } = req.body ?? {};
|
||||
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||
res.status(400).json({ error: "invalid username or password" });
|
||||
return;
|
||||
}
|
||||
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
||||
try {
|
||||
const u = await users.createUser(username, password, role);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: u.id, targetUsername: u.username,
|
||||
action: "user.created",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
|
||||
res.status(201).json({ id: u.id, username: u.username, role: u.role });
|
||||
} catch (err) {
|
||||
if (err instanceof UsernameTakenError) {
|
||||
res.status(409).json({ error: "username taken" });
|
||||
return;
|
||||
}
|
||||
logger.error({ err }, "createUser failed");
|
||||
res.status(500).json({ error: "internal" });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const targetId = req.params.id;
|
||||
// Snapshot target's username BEFORE deletion for audit
|
||||
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;
|
||||
}
|
||||
const result = users.deleteUserIfNotLastAdmin(targetId);
|
||||
if (result === "not_found") {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
if (result === "would_orphan") {
|
||||
res.status(400).json({ error: "cannot delete last admin" });
|
||||
return;
|
||||
}
|
||||
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
||||
sessions.deleteAllForUser(targetId);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: target.id, targetUsername: target.username,
|
||||
action: "user.deleted",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.post("/:id/reset-password", async (req, res) => {
|
||||
const { newPassword } = req.body ?? {};
|
||||
if (!isValidPassword(newPassword)) {
|
||||
res.status(400).json({ error: "invalid password" });
|
||||
return;
|
||||
}
|
||||
const targetId = req.params.id;
|
||||
const target = users.findById(targetId);
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
await users.changePassword(targetId, newPassword);
|
||||
// Invalidate all sessions for the target user (except current actor's if it's the same user)
|
||||
const exceptToken = targetId === req.user!.id
|
||||
? (extractSessionToken(req.headers.cookie) ?? undefined)
|
||||
: undefined;
|
||||
sessions.deleteAllForUser(targetId, exceptToken);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: target.id, targetUsername: target.username,
|
||||
action: "user.password_reset",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.patch("/:id/role", (req, res) => {
|
||||
const targetId = req.params.id;
|
||||
const { role: newRole } = req.body ?? {};
|
||||
if (newRole !== "admin" && newRole !== "member") {
|
||||
res.status(400).json({ error: "invalid role" });
|
||||
return;
|
||||
}
|
||||
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
|
||||
// so we record what actually changed; if the user is gone we'll skip audit).
|
||||
const targetBefore = users.findById(targetId);
|
||||
if (!targetBefore) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
|
||||
if (result === "not_found") {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
if (result === "would_orphan") {
|
||||
res.status(400).json({ error: "cannot demote last admin" });
|
||||
return;
|
||||
}
|
||||
// Only audit when the role actually changed
|
||||
if (targetBefore.role !== newRole) {
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
|
||||
action: "user.role_changed",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SessionStore, SessionValidation } from "../../data/sessions.js";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "tsmb_session";
|
||||
|
||||
/**
|
||||
* Validate the session cookie carried on an arbitrary HTTP-like header bag.
|
||||
* Used by Express middleware (req.headers.cookie) AND by the raw WebSocket
|
||||
* upgrade handler (req.headers.cookie) — they share this exact behavior.
|
||||
*/
|
||||
export function validateSessionFromHeaders(
|
||||
rawCookieHeader: string | undefined,
|
||||
sessions: SessionStore
|
||||
): SessionValidation | null {
|
||||
if (!rawCookieHeader) return null;
|
||||
const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
return sessions.validateAndTouch(token);
|
||||
}
|
||||
|
||||
export function extractSessionToken(rawCookieHeader: string | undefined): string | null {
|
||||
if (!rawCookieHeader) return null;
|
||||
return parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
function parseCookie(header: string, name: string): string | null {
|
||||
for (const part of header.split(";")) {
|
||||
const trimmed = part.trim();
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
if (trimmed.slice(0, eq) !== name) continue;
|
||||
try {
|
||||
return decodeURIComponent(trimmed.slice(eq + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { csrfOriginCheck } from "./csrf.js";
|
||||
|
||||
describe("csrfOriginCheck middleware", () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(csrfOriginCheck);
|
||||
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||
app.post("/", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => {
|
||||
const res = await request(app).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects POST without Origin or Referer", async () => {
|
||||
const res = await request(app).post("/");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "bad origin" });
|
||||
});
|
||||
|
||||
it("accepts POST when Origin host matches request host", async () => {
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Origin", "https://example.com");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects POST when Origin host does not match request host", async () => {
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Origin", "https://evil.com");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("accepts POST when Referer host matches and Origin is absent", async () => {
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Referer", "https://example.com/some/path");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects POST when Referer host does not match", async () => {
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Referer", "https://evil.com/some/path");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* Same-origin CSRF protection. For mutating requests, the Origin or Referer
|
||||
* header must indicate a host equal to the request's own host.
|
||||
*
|
||||
* SameSite=Lax on the session cookie blocks classic cross-site form posts;
|
||||
* this header check covers the remaining attack surface.
|
||||
*/
|
||||
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const expectedHost = req.get("host");
|
||||
const originHeader = req.get("origin");
|
||||
const refererHeader = req.get("referer");
|
||||
const headerHost = hostOf(originHeader) ?? hostOf(refererHeader);
|
||||
if (!headerHost || !expectedHost || headerHost !== expectedHost) {
|
||||
res.status(403).json({ error: "bad origin" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function hostOf(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { createRateLimit } from "./rateLimit.js";
|
||||
|
||||
describe("createRateLimit", () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
// capacity=3, refill=1/sec → first 3 succeed, then 429 until refill.
|
||||
app.use(createRateLimit({ capacity: 3, refillPerSec: 1 }));
|
||||
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
it("allows up to capacity bursts then rejects with 429", async () => {
|
||||
expect((await request(app).get("/")).status).toBe(200);
|
||||
expect((await request(app).get("/")).status).toBe(200);
|
||||
expect((await request(app).get("/")).status).toBe(200);
|
||||
const denied = await request(app).get("/");
|
||||
expect(denied.status).toBe(429);
|
||||
expect(denied.body).toEqual({ error: "rate limit exceeded" });
|
||||
expect(denied.headers["retry-after"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses per-key buckets when keyFn is provided", async () => {
|
||||
const customApp = express();
|
||||
customApp.use(
|
||||
createRateLimit({
|
||||
capacity: 1,
|
||||
refillPerSec: 0.001,
|
||||
keyFn: (req) => req.get("x-user") ?? "anon",
|
||||
})
|
||||
);
|
||||
customApp.get("/", (_req, res) => res.json({ ok: true }));
|
||||
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200);
|
||||
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429);
|
||||
// Different user, separate bucket → still has a token.
|
||||
expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
lastRefillMs: number;
|
||||
}
|
||||
|
||||
interface RateLimitOptions {
|
||||
/** Bucket capacity (max burst). */
|
||||
capacity: number;
|
||||
/** Tokens refilled per second. */
|
||||
refillPerSec: number;
|
||||
/** Optional key function; defaults to req.ip. */
|
||||
keyFn?: (req: Request) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory token-bucket rate limiter.
|
||||
*
|
||||
* Each unique key (default: req.ip) gets its own bucket. Refills continuously
|
||||
* at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no
|
||||
* token is available, returns 429 with Retry-After.
|
||||
*
|
||||
* Buckets evict themselves after 10 minutes of inactivity to bound memory.
|
||||
*/
|
||||
export function createRateLimit(options: RateLimitOptions): RequestHandler {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const EVICT_AFTER_MS = 10 * 60 * 1000;
|
||||
// Periodic eviction to bound memory under attack.
|
||||
const evict = setInterval(() => {
|
||||
const cutoff = Date.now() - EVICT_AFTER_MS;
|
||||
for (const [k, b] of buckets) {
|
||||
if (b.lastRefillMs < cutoff) buckets.delete(k);
|
||||
}
|
||||
}, 60_000);
|
||||
// Unref the timer so it doesn't keep the process alive in tests.
|
||||
if (typeof (evict as { unref?: () => void }).unref === "function") {
|
||||
(evict as { unref: () => void }).unref();
|
||||
}
|
||||
|
||||
const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown");
|
||||
|
||||
return function rateLimit(req: Request, res: Response, next: NextFunction): void {
|
||||
const key = keyFn(req);
|
||||
const now = Date.now();
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { tokens: options.capacity, lastRefillMs: now };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
const elapsedSec = (now - b.lastRefillMs) / 1000;
|
||||
b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec);
|
||||
b.lastRefillMs = now;
|
||||
if (b.tokens < 1) {
|
||||
const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec);
|
||||
res.setHeader("Retry-After", String(waitSec));
|
||||
res.status(429).json({ error: "rate limit exceeded" });
|
||||
return;
|
||||
}
|
||||
b.tokens -= 1;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 { createRequireAuth } from "./requireAuth.js";
|
||||
import { requireAdmin } from "./requireAdmin.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
describe("requireAdmin middleware", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let adminCookie: string;
|
||||
let memberCookie: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
|
||||
const member = await users.createUser("member", "pw-member-pw", "member");
|
||||
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(createRequireAuth(sessions));
|
||||
app.use(requireAdmin);
|
||||
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
const res = await request(app).get("/admin-only");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects member with 403", async () => {
|
||||
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "forbidden" });
|
||||
});
|
||||
|
||||
it("allows admin", async () => {
|
||||
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
if (req.user.role !== "admin") {
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { createRequireAuth } from "./requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
describe("requireAuth middleware", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let validToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
validToken = sessions.createSession(u.id).token;
|
||||
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(createRequireAuth(sessions));
|
||||
app.get("/protected", (req, res) => {
|
||||
res.json({ ok: true, user: (req as any).user });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("rejects requests without a session cookie", async () => {
|
||||
const res = await request(app).get("/protected");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "unauthenticated" });
|
||||
});
|
||||
|
||||
it("rejects requests with an unknown session cookie", async () => {
|
||||
const res = await request(app)
|
||||
.get("/protected")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows requests with a valid session cookie and attaches req.user", async () => {
|
||||
const res = await request(app)
|
||||
.get("/protected")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.user.username).toBe("alice");
|
||||
expect(res.body.user.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("rolls the cookie max-age forward on successful auth", async () => {
|
||||
const res = await request(app)
|
||||
.get("/protected")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const setCookieHeaders = res.headers["set-cookie"];
|
||||
const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : [];
|
||||
const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||
expect(refreshed).toBeDefined();
|
||||
expect(refreshed!).toMatch(/Max-Age=\d+/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import {
|
||||
validateSessionFromHeaders,
|
||||
extractSessionToken,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "../auth/validateSession.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
user?: { id: string; username: string; role: "admin" | "member" };
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequireAuth(sessions: SessionStore): RequestHandler {
|
||||
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = { id: result.userId, username: result.username, role: result.role };
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: req.secure,
|
||||
path: "/",
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
/**
|
||||
* The clickjacking-defence middleware is mounted at the top of
|
||||
* `createWebServer` in `server.ts`. This test asserts the exact behavior
|
||||
* we expect from that middleware in isolation. The wiring inside
|
||||
* `server.ts` is verified by code review (git diff).
|
||||
*/
|
||||
describe("security headers (anti-clickjacking)", () => {
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
next();
|
||||
});
|
||||
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||
app.post("/", (_req, res) => res.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("sets X-Frame-Options: DENY on GET responses", async () => {
|
||||
const res = await request(buildApp()).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["x-frame-options"]).toBe("DENY");
|
||||
});
|
||||
|
||||
it("sets Content-Security-Policy frame-ancestors 'none' on GET responses", async () => {
|
||||
const res = await request(buildApp()).get("/");
|
||||
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it("sets both headers on POST responses too", async () => {
|
||||
const res = await request(buildApp()).post("/");
|
||||
expect(res.headers["x-frame-options"]).toBe("DENY");
|
||||
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
|
||||
});
|
||||
});
|
||||
+100
-6
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { BotManager } from "../bot/manager.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
@@ -13,7 +14,20 @@ import { createBotRouter } from "./api/bot.js";
|
||||
import { createMusicRouter } from "./api/music.js";
|
||||
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";
|
||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||
import { csrfOriginCheck } from "./middleware/csrf.js";
|
||||
import { createRateLimit } from "./middleware/rateLimit.js";
|
||||
import { validateSessionFromHeaders } from "./auth/validateSession.js";
|
||||
|
||||
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export interface WebServerOptions {
|
||||
port: number;
|
||||
@@ -41,17 +55,51 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const logger = options.logger.child({ component: "web" });
|
||||
|
||||
if (options.config.trustProxy) {
|
||||
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
||||
app.set("trust proxy", true);
|
||||
}
|
||||
|
||||
// Security headers: prevent the WebUI from being embedded in a third-party
|
||||
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
|
||||
// of X-Frame-Options; both are set for compatibility across browsers.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: "400kb" }));
|
||||
app.use(cookieParser());
|
||||
|
||||
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) => {
|
||||
res.json({ status: "ok", version: "0.1.0" });
|
||||
});
|
||||
|
||||
app.get("/api/config/public-url", (_req, res) => {
|
||||
const raw = (options.config.publicUrl ?? "").trim();
|
||||
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||
});
|
||||
|
||||
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
|
||||
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
|
||||
// 3 req per minute per IP for /setup (more limited; first-run is rare).
|
||||
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
|
||||
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
|
||||
app.use("/api/session/login", loginLimit);
|
||||
app.use("/api/session/setup", setupLimit);
|
||||
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
|
||||
|
||||
// ─── Gates for everything else under /api ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions);
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
// ─── Protected routes ───────────────────────────────────────────────────
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(
|
||||
@@ -75,11 +123,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||
);
|
||||
// admin-only routes
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
|
||||
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ status: "ok", version: "0.1.0" });
|
||||
});
|
||||
|
||||
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||
if (options.staticDir) {
|
||||
app.use(express.static(options.staticDir));
|
||||
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
|
||||
@@ -91,22 +139,68 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
logger.error({ err }, "HTTP server error");
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||
// ─── WebSocket with manual upgrade auth ────────────────────────────────
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
wss.on("error", (err) => {
|
||||
logger.error({ err }, "WebSocket server error");
|
||||
});
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (req.url !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const reqHost = req.headers.host;
|
||||
const originHeader = req.headers.origin;
|
||||
if (originHeader) {
|
||||
let originHost: string | null = null;
|
||||
try {
|
||||
originHost = new URL(originHeader).host;
|
||||
} catch {
|
||||
// fall through; treat as missing/invalid origin
|
||||
}
|
||||
if (!originHost || originHost !== reqHost) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
|
||||
if (!result) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
(ws as unknown as { userId: string }).userId = result.userId;
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||
|
||||
// ─── Session cleanup interval ──────────────────────────────────────────
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(options.port, () => {
|
||||
logger.info({ port: options.port }, "Web server started");
|
||||
cleanupTimer = setInterval(() => {
|
||||
try {
|
||||
sessions.cleanupExpired();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "session cleanup failed");
|
||||
}
|
||||
}, SESSION_CLEANUP_INTERVAL_MS);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
stop(): void {
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
cleanupWs();
|
||||
wss.close();
|
||||
server.close();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import { WebSocketServer, WebSocket as WSClient } from "ws";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { createDatabase, type BotDatabase } from "../data/database.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
|
||||
|
||||
function buildServer(sessions: ReturnType<typeof createSessionStore>) {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
wss.on("connection", (ws) => ws.send("hello"));
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (req.url !== "/ws") return socket.destroy();
|
||||
const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
|
||||
if (!r) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
||||
});
|
||||
return { server, wss };
|
||||
}
|
||||
|
||||
describe("WebSocket auth at upgrade", () => {
|
||||
let botDb: BotDatabase;
|
||||
let httpServer: http.Server;
|
||||
let port: number;
|
||||
let validToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
validToken = sessions.createSession(u.id).token;
|
||||
|
||||
const { server } = buildServer(sessions);
|
||||
httpServer = server;
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
|
||||
port = (httpServer.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("rejects upgrade without cookie (server-side close before open)", async () => {
|
||||
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`);
|
||||
const result = await new Promise<string>((resolve) => {
|
||||
ws.on("open", () => resolve("opened"));
|
||||
ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`));
|
||||
ws.on("error", () => resolve("error"));
|
||||
});
|
||||
expect(result).toMatch(/^status:401$|^error$/);
|
||||
});
|
||||
|
||||
it("accepts upgrade with a valid cookie", async () => {
|
||||
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, {
|
||||
headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` },
|
||||
});
|
||||
const msg = await new Promise<string>((resolve, reject) => {
|
||||
ws.on("message", (data) => resolve(data.toString()));
|
||||
ws.on("error", reject);
|
||||
});
|
||||
expect(msg).toBe("hello");
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user