mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
b0b61f8fce
commit
a73f797bcb
18 files changed
+292
-60
No files matched your search
@@ -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({
|
||||
|
||||
+13
-13
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+47
-4
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user