mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): add /api/users CRUD (list, create, delete, reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
f46b37192f
commit
175b8e6065
5 files changed
+257
No files matched your search
@@ -53,4 +53,23 @@ describe("UserStore", () => {
|
|||||||
expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false);
|
expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false);
|
||||||
expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true);
|
expect(await users.verifyPassword("new", 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");
|
||||||
|
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");
|
||||||
|
expect(users.deleteUser(u.id)).toBe(true);
|
||||||
|
expect(users.countUsers()).toBe(0);
|
||||||
|
expect(users.deleteUser("not-a-real-id")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -19,6 +19,8 @@ export interface UserStore {
|
|||||||
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 }>;
|
||||||
|
deleteUser(id: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UsernameTakenError extends Error {
|
export class UsernameTakenError extends Error {
|
||||||
@@ -42,6 +44,10 @@ export function createUserStore(db: Database.Database): UserStore {
|
|||||||
const updatePasswordStmt = db.prepare(
|
const updatePasswordStmt = db.prepare(
|
||||||
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
||||||
);
|
);
|
||||||
|
const listUsersStmt = db.prepare(
|
||||||
|
"SELECT id, username, createdAt FROM users ORDER BY createdAt ASC"
|
||||||
|
);
|
||||||
|
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
countUsers() {
|
countUsers() {
|
||||||
@@ -79,5 +85,14 @@ export function createUserStore(db: Database.Database): UserStore {
|
|||||||
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||||
updatePasswordStmt.run(hash, Date.now(), userId);
|
updatePasswordStmt.run(hash, Date.now(), userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listUsers() {
|
||||||
|
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number }>;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser(id) {
|
||||||
|
const result = deleteUserStmt.run(id);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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 { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createUsersRouter } from "./users.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
function makeApp(users: UserStore, sessions: SessionStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
const requireAuth = createRequireAuth(sessions);
|
||||||
|
app.use("/api", requireAuth);
|
||||||
|
app.use("/api/users", createUsersRouter(users, sessions, 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(users, sessions);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice");
|
||||||
|
aliceId = alice.id;
|
||||||
|
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
const bob = await users.createUser("bob", "pw-bob-bob");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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,
|
||||||
|
logger: Logger
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", (_req, res) => {
|
||||||
|
res.json({ users: users.listUsers() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||||
|
res.status(400).json({ error: "invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const u = await users.createUser(username, password);
|
||||||
|
logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created");
|
||||||
|
res.status(201).json({ id: u.id, username: u.username });
|
||||||
|
} 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;
|
||||||
|
if (targetId === req.user!.id) {
|
||||||
|
res.status(400).json({ error: "cannot delete self" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = users.deleteUser(targetId);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessions.deleteAllForUser(targetId);
|
||||||
|
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 ? undefined : undefined;
|
||||||
|
sessions.deleteAllForUser(targetId, exceptToken);
|
||||||
|
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { createMusicRouter } from "./api/music.js";
|
|||||||
import { createPlayerRouter } from "./api/player.js";
|
import { createPlayerRouter } from "./api/player.js";
|
||||||
import { createAuthRouter } from "./api/auth.js";
|
import { createAuthRouter } from "./api/auth.js";
|
||||||
import { createSessionRouter } from "./api/session.js";
|
import { createSessionRouter } from "./api/session.js";
|
||||||
|
import { createUsersRouter } from "./api/users.js";
|
||||||
import { setupWebSocket } from "./websocket.js";
|
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";
|
||||||
@@ -100,6 +101,7 @@ 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, logger));
|
||||||
|
|
||||||
// ─── Static SPA (public) ────────────────────────────────────────────────
|
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||||
if (options.staticDir) {
|
if (options.staticDir) {
|
||||||
|
|||||||
Reference in new issue
Block a user