From a73f797bcbcd8e9be60f48b04fe222bd3622ddac Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 15:35:15 +0800 Subject: [PATCH] feat(auth): two-role permission system (admin/member) Adds an admin/member role to WebUI auth. /api/users and /api/audit are now gated by a requireAdmin middleware; all other authenticated endpoints accept both roles. Schema migration defaults all existing users to admin to preserve access. POST /api/users defaults new users to member; first-run setup always creates an admin. Adds PATCH /api/users/:id/role with last-admin demotion and deletion guards. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/data/audit.ts | 3 +- src/data/database.test.ts | 2 +- src/data/database.ts | 9 +++- src/data/sessions.test.ts | 3 +- src/data/sessions.ts | 7 +-- src/data/users.test.ts | 54 ++++++++++++++----- src/data/users.ts | 42 ++++++++++----- src/web/api/audit.test.ts | 2 +- src/web/api/session.test.ts | 26 +++++----- src/web/api/session.ts | 6 +-- src/web/api/users.test.ts | 69 ++++++++++++++++++++++++- src/web/api/users.ts | 51 ++++++++++++++++-- src/web/middleware/requireAdmin.test.ts | 50 ++++++++++++++++++ src/web/middleware/requireAdmin.ts | 13 +++++ src/web/middleware/requireAuth.test.ts | 3 +- src/web/middleware/requireAuth.ts | 4 +- src/web/server.ts | 6 ++- src/web/websocket-auth.test.ts | 2 +- 18 files changed, 292 insertions(+), 60 deletions(-) create mode 100644 src/web/middleware/requireAdmin.test.ts create mode 100644 src/web/middleware/requireAdmin.ts diff --git a/src/data/audit.ts b/src/data/audit.ts index 4e639de..6f48171 100644 --- a/src/data/audit.ts +++ b/src/data/audit.ts @@ -5,7 +5,8 @@ export type AuditAction = | "user.created" | "user.deleted" | "user.password_reset" - | "user.password_changed"; + | "user.password_changed" + | "user.role_changed"; export interface AuditEntry { id: number; diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 90436b8..7fc96f0 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -33,7 +33,7 @@ describe("database", () => { 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", "updatedAt", "username"]); + 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(); diff --git a/src/data/database.ts b/src/data/database.ts index 8d9fcf3..f911756 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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 { @@ -133,7 +139,8 @@ function initTables(db: Database.Database): void { username TEXT NOT NULL UNIQUE COLLATE NOCASE, passwordHash TEXT NOT NULL, createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL + updatedAt INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' ); CREATE TABLE IF NOT EXISTS sessions ( diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts index 376690b..f0a491a 100644 --- a/src/data/sessions.test.ts +++ b/src/data/sessions.test.ts @@ -18,7 +18,7 @@ describe("SessionStore", () => { botDb = createDatabase(":memory:"); users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); - const u = await users.createUser("alice", "pw"); + const u = await users.createUser("alice", "pw-alice", "admin"); userId = u.id; }); @@ -40,6 +40,7 @@ describe("SessionStore", () => { 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", () => { diff --git a/src/data/sessions.ts b/src/data/sessions.ts index ce29c4f..106b3ae 100644 --- a/src/data/sessions.ts +++ b/src/data/sessions.ts @@ -7,6 +7,7 @@ export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export interface SessionValidation { userId: string; username: string; + role: "admin" | "member"; } export interface SessionStore { @@ -26,7 +27,7 @@ export function createSessionStore(db: Database.Database): SessionStore { "INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)" ); const selectStmt = db.prepare(` - SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username + 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 = ? `); @@ -54,7 +55,7 @@ export function createSessionStore(db: Database.Database): SessionStore { if (!rawToken) return null; const id = hashToken(rawToken); const row = selectStmt.get(id) as - | { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string } + | { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string } | undefined; if (!row) return null; const now = Date.now(); @@ -65,7 +66,7 @@ export function createSessionStore(db: Database.Database): SessionStore { if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { touchStmt.run(now, now + SESSION_TTL_MS, id); } - return { userId: row.userId, username: row.username }; + return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" }; }, deleteSession(rawToken) { diff --git a/src/data/users.test.ts b/src/data/users.test.ts index e89e52e..c365903 100644 --- a/src/data/users.test.ts +++ b/src/data/users.test.ts @@ -20,26 +20,26 @@ describe("UserStore", () => { }); it("createUser stores the user and bumps countUsers", async () => { - const u = await users.createUser("alice", "pw-hunter2"); + 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"); + 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"); - await expect(users.createUser("alice", "pw2")).rejects.toBeInstanceOf(UsernameTakenError); + 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"); + 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); @@ -47,16 +47,16 @@ describe("UserStore", () => { }); it("changePassword updates the hash so the old password no longer verifies", async () => { - const u = await users.createUser("alice", "old"); - await users.changePassword(u.id, "new"); + 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", row!.passwordHash)).toBe(false); - expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true); + 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"); - await users.createUser("bob", "pw-bob"); + 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"); @@ -67,7 +67,7 @@ describe("UserStore", () => { }); it("deleteUser removes the row and returns true; returns false for unknown id", async () => { - const u = await users.createUser("alice", "pw-alice"); + 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); @@ -92,4 +92,34 @@ describe("UserStore", () => { 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"); + }); }); diff --git a/src/data/users.ts b/src/data/users.ts index c7becc7..1ad4728 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -4,24 +4,29 @@ 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; - createUser(username: string, password: string): Promise; + countAdmins(): number; + createUser(username: string, password: string, role: UserRole): Promise; createFirstUser(username: string, password: string): Promise; findByUsername(username: string): UserRow | null; findById(id: string): UserRow | null; verifyPassword(plain: string, hash: string): Promise; changePassword(userId: string, newPassword: string): Promise; - listUsers(): Array<{ id: string; username: string; createdAt: number }>; + setRole(userId: string, role: UserRole): boolean; deleteUser(id: string): boolean; + listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>; } export class UsernameTakenError extends Error { @@ -33,20 +38,24 @@ export class UsernameTakenError extends Error { 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) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)" ); const findByUsernameStmt = db.prepare( - "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE username = ? COLLATE NOCASE" + "SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE" ); const findByIdStmt = db.prepare( - "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE id = ?" + "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 FROM users ORDER BY createdAt ASC" + "SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC" ); const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?"); @@ -55,19 +64,23 @@ export function createUserStore(db: Database.Database): UserStore { return (countStmt.get() as { n: number }).n; }, - async createUser(username, password) { + 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); + 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 }; + return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role }; }, async createFirstUser(username, password) { @@ -78,14 +91,14 @@ export function createUserStore(db: Database.Database): UserStore { const count = (countStmt.get() as { n: number }).n; if (count !== 0) return null; try { - insertStmt.run(id, username, hash, now, now); + 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 } as UserRow; + return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow; }); return run(); }, @@ -107,8 +120,13 @@ export function createUserStore(db: Database.Database): UserStore { updatePasswordStmt.run(hash, Date.now(), userId); }, + setRole(userId, role) { + const result = updateRoleStmt.run(role, Date.now(), userId); + return result.changes > 0; + }, + listUsers() { - return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number }>; + return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>; }, deleteUser(id) { diff --git a/src/web/api/audit.test.ts b/src/web/api/audit.test.ts index 90e27f9..5e65d48 100644 --- a/src/web/api/audit.test.ts +++ b/src/web/api/audit.test.ts @@ -20,7 +20,7 @@ describe("audit router", () => { const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); const audit = createAuditStore(botDb.db); - const alice = await users.createUser("alice", "pw-alice"); + 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({ diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index b01f4ea..a46f081 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -61,7 +61,7 @@ describe("session router", () => { }); it("POST /setup returns 409 once a user already exists", async () => { - await users.createUser("admin", "pw"); + await users.createUser("admin", "pw-admin-pw", "admin"); const res = await request(app) .post("/api/session/setup") .send({ username: "alice", password: "pw" }); @@ -70,7 +70,7 @@ describe("session router", () => { }); it("POST /login returns 401 with constant-time delay on bad credentials", async () => { - await users.createUser("alice", "correct"); + await users.createUser("alice", "correct-pw-pw", "admin"); const start = Date.now(); const res = await request(app) .post("/api/session/login") @@ -81,20 +81,20 @@ describe("session router", () => { }, 10_000); it("POST /login sets a session cookie on success", async () => { - await users.createUser("alice", "pw"); + await users.createUser("alice", "pw-alice", "admin"); const res = await request(app) .post("/api/session/login") - .send({ username: "alice", password: "pw" }); + .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"); + await users.createUser("alice", "pw-alice", "admin"); const loginRes = await request(app) .post("/api/session/login") - .send({ username: "alice", password: "pw" }); + .send({ username: "alice", password: "pw-alice" }); const cookie = extractCookie(loginRes); const me = await request(app).get("/api/session/me").set("Cookie", cookie); @@ -106,10 +106,10 @@ describe("session router", () => { }); it("POST /logout deletes the session and clears the cookie", async () => { - await users.createUser("alice", "pw"); + await users.createUser("alice", "pw-alice", "admin"); const loginRes = await request(app) .post("/api/session/login") - .send({ username: "alice", password: "pw" }); + .send({ username: "alice", password: "pw-alice" }); const cookie = extractCookie(loginRes); const logout = await request(app).post("/api/session/logout").set("Cookie", cookie); @@ -120,24 +120,24 @@ describe("session router", () => { }); it("POST /change-password requires old password and invalidates other sessions", async () => { - const u = await users.createUser("alice", "old"); + 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" }) + 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" }) + 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: "new" }); + .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", newPassword: "newpassword" }); + .send({ oldPassword: "old-pw-pw", newPassword: "newpassword" }); expect(ok.status).toBe(204); const meA = await request(app).get("/api/session/me").set("Cookie", cookieA); diff --git a/src/web/api/session.ts b/src/web/api/session.ts index e595b89..21e8721 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -60,7 +60,7 @@ export function createSessionRouter( res.status(401).json({ error: "unauthenticated" }); return; } - req.user = { id: result.userId, username: result.username }; + req.user = { id: result.userId, username: result.username, role: result.role }; const token = extractSessionToken(req.headers.cookie); if (token) setSessionCookie(res, token); next(); @@ -98,7 +98,7 @@ export function createSessionRouter( 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 }); + res.json({ id: user.id, username: user.username, role: user.role }); } catch (err) { logger.error({ err }, "setup failed"); res.status(500).json({ error: "internal" }); @@ -120,7 +120,7 @@ export function createSessionRouter( } const { token } = sessions.createSession(user.id); setSessionCookie(res, token); - res.json({ id: user.id, username: user.username }); + res.json({ id: user.id, username: user.username, role: user.role }); }); router.post("/logout", (req, res) => { diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index f2d8742..67efd4c 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -36,10 +36,10 @@ describe("users router", () => { users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); app = makeApp(botDb, users, sessions); - const alice = await users.createUser("alice", "pw-alice"); + 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"); + const bob = await users.createUser("bob", "pw-bob-bob", "member"); bobId = bob.id; }); @@ -189,4 +189,69 @@ describe("users router", () => { // 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); + }); }); diff --git a/src/web/api/users.ts b/src/web/api/users.ts index 0204f16..b4f54b6 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -27,13 +27,14 @@ export function createUsersRouter( }); router.post("/", async (req, res) => { - const { username, password } = req.body ?? {}; + 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); + const u = await users.createUser(username, password, role); try { audit.record({ actorId: req.user!.id, actorUsername: req.user!.username, @@ -43,8 +44,8 @@ export function createUsersRouter( } catch (auditErr) { logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed"); } - logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created"); - res.status(201).json({ id: u.id, username: u.username }); + 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" }); @@ -66,6 +67,10 @@ export function createUsersRouter( res.status(400).json({ error: "cannot delete self" }); return; } + if (target.role === "admin" && users.countAdmins() <= 1) { + res.status(400).json({ error: "cannot delete last admin" }); + return; + } const deleted = users.deleteUser(targetId); if (!deleted) { res.status(404).json({ error: "not found" }); @@ -116,5 +121,43 @@ export function createUsersRouter( 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; + } + const target = users.findById(targetId); + if (!target) { + res.status(404).json({ error: "not found" }); + return; + } + if (target.role === newRole) { + res.status(204).end(); + return; + } + if (target.role === "admin" && newRole === "member" && users.countAdmins() <= 1) { + res.status(400).json({ error: "cannot demote last admin" }); + return; + } + const changed = users.setRole(targetId, newRole); + if (!changed) { + res.status(404).json({ error: "not found" }); + return; + } + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: target.id, targetUsername: target.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; } diff --git a/src/web/middleware/requireAdmin.test.ts b/src/web/middleware/requireAdmin.test.ts new file mode 100644 index 0000000..40ccd02 --- /dev/null +++ b/src/web/middleware/requireAdmin.test.ts @@ -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); + }); +}); diff --git a/src/web/middleware/requireAdmin.ts b/src/web/middleware/requireAdmin.ts new file mode 100644 index 0000000..d37e08b --- /dev/null +++ b/src/web/middleware/requireAdmin.ts @@ -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(); +} diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index c5c37a1..0025bd1 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -17,7 +17,7 @@ describe("requireAuth middleware", () => { botDb = createDatabase(":memory:"); const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); - const u = await users.createUser("alice", "pw"); + const u = await users.createUser("alice", "pw-alice", "admin"); validToken = sessions.createSession(u.id).token; app = express(); @@ -52,6 +52,7 @@ describe("requireAuth middleware", () => { 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 () => { diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index 8ec67bd..131235f 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -9,7 +9,7 @@ import { declare module "express-serve-static-core" { interface Request { - user?: { id: string; username: string }; + user?: { id: string; username: string; role: "admin" | "member" }; } } @@ -21,7 +21,7 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler { res.status(401).json({ error: "unauthenticated" }); return; } - req.user = { id: result.userId, username: result.username }; + 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, { diff --git a/src/web/server.ts b/src/web/server.ts index d596d72..f1b8785 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -22,6 +22,7 @@ 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 { validateSessionFromHeaders } from "./auth/validateSession.js"; @@ -104,8 +105,9 @@ export function createWebServer(options: WebServerOptions): WebServer { "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); - app.use("/api/users", createUsersRouter(users, sessions, audit, logger)); - app.use("/api/audit", createAuditRouter(audit)); + // admin-only routes + app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger)); + app.use("/api/audit", requireAdmin, createAuditRouter(audit)); // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) { diff --git a/src/web/websocket-auth.test.ts b/src/web/websocket-auth.test.ts index efd9a9d..5424daf 100644 --- a/src/web/websocket-auth.test.ts +++ b/src/web/websocket-auth.test.ts @@ -36,7 +36,7 @@ describe("WebSocket auth at upgrade", () => { botDb = createDatabase(":memory:"); const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); - const u = await users.createUser("alice", "pw"); + const u = await users.createUser("alice", "pw-alice", "admin"); validToken = sessions.createSession(u.id).token; const { server } = buildServer(sessions);