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) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-27 15:35:15 +08:00
1 parent b0b61f8fce
commit a73f797bcb
18 files changed
+292 -60

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@ export type AuditAction =
| "user.created" | "user.created"
| "user.deleted" | "user.deleted"
| "user.password_reset" | "user.password_reset"
| "user.password_changed"; | "user.password_changed"
| "user.role_changed";
export interface AuditEntry { export interface AuditEntry {
id: number; id: number;
+1 -1
View File
@@ -33,7 +33,7 @@ describe("database", () => {
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userCols.map((c) => c.name).sort(); 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 sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
const sessionColNames = sessionCols.map((c) => c.name).sort(); const sessionColNames = sessionCols.map((c) => c.name).sort();
+8 -1
View File
@@ -97,6 +97,12 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) { if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT"); 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 { function initTables(db: Database.Database): void {
@@ -133,7 +139,8 @@ function initTables(db: Database.Database): void {
username TEXT NOT NULL UNIQUE COLLATE NOCASE, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL, passwordHash TEXT NOT NULL,
createdAt INTEGER 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 ( CREATE TABLE IF NOT EXISTS sessions (
+2 -1
View File
@@ -18,7 +18,7 @@ describe("SessionStore", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
users = createUserStore(botDb.db); users = createUserStore(botDb.db);
sessions = createSessionStore(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; userId = u.id;
}); });
@@ -40,6 +40,7 @@ describe("SessionStore", () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.userId).toBe(userId); expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice"); expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
}); });
it("validateAndTouch returns null and deletes the row for an expired session", () => { it("validateAndTouch returns null and deletes the row for an expired session", () => {
+4 -3
View File
@@ -7,6 +7,7 @@ export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export interface SessionValidation { export interface SessionValidation {
userId: string; userId: string;
username: string; username: string;
role: "admin" | "member";
} }
export interface SessionStore { export interface SessionStore {
@@ -26,7 +27,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)" "INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
); );
const selectStmt = db.prepare(` 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 FROM sessions s INNER JOIN users u ON u.id = s.userId
WHERE s.id = ? WHERE s.id = ?
`); `);
@@ -54,7 +55,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
if (!rawToken) return null; if (!rawToken) return null;
const id = hashToken(rawToken); const id = hashToken(rawToken);
const row = selectStmt.get(id) as 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; | undefined;
if (!row) return null; if (!row) return null;
const now = Date.now(); const now = Date.now();
@@ -65,7 +66,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
touchStmt.run(now, now + SESSION_TTL_MS, id); 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) { deleteSession(rawToken) {
+42 -12
View File
@@ -20,26 +20,26 @@ describe("UserStore", () => {
}); });
it("createUser stores the user and bumps countUsers", async () => { 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.id).toMatch(/^[0-9a-f-]{36}$/);
expect(u.username).toBe("alice"); expect(u.username).toBe("alice");
expect(users.countUsers()).toBe(1); expect(users.countUsers()).toBe(1);
}); });
it("findByUsername is case-insensitive and returns null for missing", async () => { 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("alice")).not.toBeNull(); expect(users.findByUsername("alice")).not.toBeNull();
expect(users.findByUsername("bob")).toBeNull(); expect(users.findByUsername("bob")).toBeNull();
}); });
it("createUser rejects duplicate usernames (case-insensitive)", async () => { it("createUser rejects duplicate usernames (case-insensitive)", async () => {
await users.createUser("Alice", "pw"); await users.createUser("Alice", "pw-alice", "member");
await expect(users.createUser("alice", "pw2")).rejects.toBeInstanceOf(UsernameTakenError); await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
}); });
it("verifyPassword accepts correct password and rejects wrong one", async () => { 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"); const row = users.findByUsername("alice");
expect(row).not.toBeNull(); expect(row).not.toBeNull();
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true); 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 () => { it("changePassword updates the hash so the old password no longer verifies", async () => {
const u = await users.createUser("alice", "old"); const u = await users.createUser("alice", "old-pw-pw", "member");
await users.changePassword(u.id, "new"); await users.changePassword(u.id, "new-pw-pw");
const row = users.findByUsername("alice"); const row = users.findByUsername("alice");
expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false); expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true); expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
}); });
it("listUsers returns id+username+createdAt ascending, no password hash", async () => { it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
await users.createUser("alice", "pw-alice"); await users.createUser("alice", "pw-alice", "member");
await users.createUser("bob", "pw-bob"); await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers(); const list = users.listUsers();
expect(list).toHaveLength(2); expect(list).toHaveLength(2);
expect(list[0].username).toBe("alice"); 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 () => { 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.deleteUser(u.id)).toBe(true);
expect(users.countUsers()).toBe(0); expect(users.countUsers()).toBe(0);
expect(users.deleteUser("not-a-real-id")).toBe(false); expect(users.deleteUser("not-a-real-id")).toBe(false);
@@ -92,4 +92,34 @@ describe("UserStore", () => {
expect(created).toHaveLength(1); expect(created).toHaveLength(1);
expect(users.countUsers()).toBe(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");
});
}); });
+30 -12
View File
@@ -4,24 +4,29 @@ import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12; const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member";
export interface UserRow { export interface UserRow {
id: string; id: string;
username: string; username: string;
passwordHash: string; passwordHash: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
role: UserRole;
} }
export interface UserStore { export interface UserStore {
countUsers(): number; countUsers(): number;
createUser(username: string, password: string): Promise<UserRow>; countAdmins(): number;
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
createFirstUser(username: string, password: string): Promise<UserRow | null>; createFirstUser(username: string, password: string): Promise<UserRow | null>;
findByUsername(username: string): UserRow | null; findByUsername(username: string): UserRow | null;
findById(id: string): UserRow | null; findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>; verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>; changePassword(userId: string, newPassword: string): Promise<void>;
listUsers(): Array<{ id: string; username: string; createdAt: number }>; setRole(userId: string, role: UserRole): boolean;
deleteUser(id: string): boolean; deleteUser(id: string): boolean;
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
} }
export class UsernameTakenError extends Error { export class UsernameTakenError extends Error {
@@ -33,20 +38,24 @@ export class UsernameTakenError extends Error {
export function createUserStore(db: Database.Database): UserStore { export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users"); 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( 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( 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( 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( const updatePasswordStmt = db.prepare(
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?" "UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
); );
const updateRoleStmt = db.prepare(
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare( 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 = ?"); 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; 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 hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID(); const id = randomUUID();
const now = Date.now(); const now = Date.now();
try { try {
insertStmt.run(id, username, hash, now, now); insertStmt.run(id, username, hash, now, now, role);
} catch (err) { } catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") { if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new UsernameTakenError(username); throw new UsernameTakenError(username);
} }
throw err; 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) { async createFirstUser(username, password) {
@@ -78,14 +91,14 @@ export function createUserStore(db: Database.Database): UserStore {
const count = (countStmt.get() as { n: number }).n; const count = (countStmt.get() as { n: number }).n;
if (count !== 0) return null; if (count !== 0) return null;
try { try {
insertStmt.run(id, username, hash, now, now); insertStmt.run(id, username, hash, now, now, "admin");
} catch (err) { } catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") { if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
return null; return null;
} }
throw err; 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(); return run();
}, },
@@ -107,8 +120,13 @@ export function createUserStore(db: Database.Database): UserStore {
updatePasswordStmt.run(hash, Date.now(), userId); updatePasswordStmt.run(hash, Date.now(), userId);
}, },
setRole(userId, role) {
const result = updateRoleStmt.run(role, Date.now(), userId);
return result.changes > 0;
},
listUsers() { 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) { deleteUser(id) {
+1 -1
View File
@@ -20,7 +20,7 @@ describe("audit router", () => {
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db); const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(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}`; cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
audit.record({ audit.record({
+13 -13
View File
@@ -61,7 +61,7 @@ describe("session router", () => {
}); });
it("POST /setup returns 409 once a user already exists", async () => { 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) const res = await request(app)
.post("/api/session/setup") .post("/api/session/setup")
.send({ username: "alice", password: "pw" }); .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 () => { 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 start = Date.now();
const res = await request(app) const res = await request(app)
.post("/api/session/login") .post("/api/session/login")
@@ -81,20 +81,20 @@ describe("session router", () => {
}, 10_000); }, 10_000);
it("POST /login sets a session cookie on success", async () => { 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) const res = await request(app)
.post("/api/session/login") .post("/api/session/login")
.send({ username: "alice", password: "pw" }); .send({ username: "alice", password: "pw-alice" });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.username).toBe("alice"); expect(res.body.username).toBe("alice");
extractCookie(res); extractCookie(res);
}); });
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => { 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) const loginRes = await request(app)
.post("/api/session/login") .post("/api/session/login")
.send({ username: "alice", password: "pw" }); .send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes); const cookie = extractCookie(loginRes);
const me = await request(app).get("/api/session/me").set("Cookie", cookie); 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 () => { 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) const loginRes = await request(app)
.post("/api/session/login") .post("/api/session/login")
.send({ username: "alice", password: "pw" }); .send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes); const cookie = extractCookie(loginRes);
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie); 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 () => { 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( 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( 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) const wrongOld = await request(app)
.post("/api/session/change-password") .post("/api/session/change-password")
.set("Cookie", cookieA) .set("Cookie", cookieA)
.send({ oldPassword: "WRONG", newPassword: "new" }); .send({ oldPassword: "WRONG", newPassword: "newpassword" });
expect(wrongOld.status).toBe(401); expect(wrongOld.status).toBe(401);
const ok = await request(app) const ok = await request(app)
.post("/api/session/change-password") .post("/api/session/change-password")
.set("Cookie", cookieA) .set("Cookie", cookieA)
.send({ oldPassword: "old", newPassword: "newpassword" }); .send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
expect(ok.status).toBe(204); expect(ok.status).toBe(204);
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA); const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
+3 -3
View File
@@ -60,7 +60,7 @@ export function createSessionRouter(
res.status(401).json({ error: "unauthenticated" }); res.status(401).json({ error: "unauthenticated" });
return; 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); const token = extractSessionToken(req.headers.cookie);
if (token) setSessionCookie(res, token); if (token) setSessionCookie(res, token);
next(); next();
@@ -98,7 +98,7 @@ export function createSessionRouter(
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed"); logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
} }
logger.info({ userId: user.id, username }, "First admin created"); logger.info({ userId: user.id, username }, "First admin created");
res.json({ id: user.id, username: user.username }); res.json({ id: user.id, username: user.username, role: user.role });
} catch (err) { } catch (err) {
logger.error({ err }, "setup failed"); logger.error({ err }, "setup failed");
res.status(500).json({ error: "internal" }); res.status(500).json({ error: "internal" });
@@ -120,7 +120,7 @@ export function createSessionRouter(
} }
const { token } = sessions.createSession(user.id); const { token } = sessions.createSession(user.id);
setSessionCookie(res, token); 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) => { router.post("/logout", (req, res) => {
+67 -2
View File
@@ -36,10 +36,10 @@ describe("users router", () => {
users = createUserStore(botDb.db); users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db); sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions); 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; aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; 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; bobId = bob.id;
}); });
@@ -189,4 +189,69 @@ describe("users router", () => {
// Bob's session should be dead // Bob's session should be dead
expect(sessions.validateAndTouch(bobToken)).toBeNull(); 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);
});
}); });
+47 -4
View File
@@ -27,13 +27,14 @@ export function createUsersRouter(
}); });
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
const { username, password } = req.body ?? {}; const { username, password, role: roleInput } = req.body ?? {};
if (!isValidUsername(username) || !isValidPassword(password)) { if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" }); res.status(400).json({ error: "invalid username or password" });
return; return;
} }
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
try { try {
const u = await users.createUser(username, password); const u = await users.createUser(username, password, role);
try { try {
audit.record({ audit.record({
actorId: req.user!.id, actorUsername: req.user!.username, actorId: req.user!.id, actorUsername: req.user!.username,
@@ -43,8 +44,8 @@ export function createUsersRouter(
} catch (auditErr) { } catch (auditErr) {
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed"); logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
} }
logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created"); logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
res.status(201).json({ id: u.id, username: u.username }); res.status(201).json({ id: u.id, username: u.username, role: u.role });
} catch (err) { } catch (err) {
if (err instanceof UsernameTakenError) { if (err instanceof UsernameTakenError) {
res.status(409).json({ error: "username taken" }); res.status(409).json({ error: "username taken" });
@@ -66,6 +67,10 @@ export function createUsersRouter(
res.status(400).json({ error: "cannot delete self" }); res.status(400).json({ error: "cannot delete self" });
return; return;
} }
if (target.role === "admin" && users.countAdmins() <= 1) {
res.status(400).json({ error: "cannot delete last admin" });
return;
}
const deleted = users.deleteUser(targetId); const deleted = users.deleteUser(targetId);
if (!deleted) { if (!deleted) {
res.status(404).json({ error: "not found" }); res.status(404).json({ error: "not found" });
@@ -116,5 +121,43 @@ export function createUsersRouter(
res.status(204).end(); 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; return router;
} }
+50
View File
@@ -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);
});
});
+13
View File
@@ -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();
}
+2 -1
View File
@@ -17,7 +17,7 @@ describe("requireAuth middleware", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(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; validToken = sessions.createSession(u.id).token;
app = express(); app = express();
@@ -52,6 +52,7 @@ describe("requireAuth middleware", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.ok).toBe(true); expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice"); 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 () => { it("rolls the cookie max-age forward on successful auth", async () => {
+2 -2
View File
@@ -9,7 +9,7 @@ import {
declare module "express-serve-static-core" { declare module "express-serve-static-core" {
interface Request { 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" }); res.status(401).json({ error: "unauthenticated" });
return; 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); const token = extractSessionToken(req.headers.cookie);
if (token) { if (token) {
res.cookie(SESSION_COOKIE_NAME, token, { res.cookie(SESSION_COOKIE_NAME, token, {
+4 -2
View File
@@ -22,6 +22,7 @@ import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
import { createRequireAuth } from "./middleware/requireAuth.js"; import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
import { csrfOriginCheck } from "./middleware/csrf.js"; import { csrfOriginCheck } from "./middleware/csrf.js";
import { validateSessionFromHeaders } from "./auth/validateSession.js"; import { validateSessionFromHeaders } from "./auth/validateSession.js";
@@ -104,8 +105,9 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
); );
app.use("/api/users", createUsersRouter(users, sessions, audit, logger)); // admin-only routes
app.use("/api/audit", createAuditRouter(audit)); app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// ─── Static SPA (public) ──────────────────────────────────────────────── // ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) { if (options.staticDir) {
+1 -1
View File
@@ -36,7 +36,7 @@ describe("WebSocket auth at upgrade", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(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; validToken = sessions.createSession(u.id).token;
const { server } = buildServer(sessions); const { server } = buildServer(sessions);