From 175b8e60651657bb9431e964328553bce7bfeb48 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 14:41:58 +0800 Subject: [PATCH] feat(auth): add /api/users CRUD (list, create, delete, reset-password) Co-Authored-By: Claude Sonnet 4.6 --- src/data/users.test.ts | 19 ++++++ src/data/users.ts | 15 +++++ src/web/api/users.test.ts | 138 ++++++++++++++++++++++++++++++++++++++ src/web/api/users.ts | 83 +++++++++++++++++++++++ src/web/server.ts | 2 + 5 files changed, 257 insertions(+) create mode 100644 src/web/api/users.test.ts create mode 100644 src/web/api/users.ts diff --git a/src/data/users.test.ts b/src/data/users.test.ts index 0bbd30d..65fc7f1 100644 --- a/src/data/users.test.ts +++ b/src/data/users.test.ts @@ -53,4 +53,23 @@ describe("UserStore", () => { expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false); 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); + }); }); diff --git a/src/data/users.ts b/src/data/users.ts index d79ca40..42e9450 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -19,6 +19,8 @@ export interface UserStore { 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 }>; + deleteUser(id: string): boolean; } export class UsernameTakenError extends Error { @@ -42,6 +44,10 @@ export function createUserStore(db: Database.Database): UserStore { const updatePasswordStmt = db.prepare( "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 { countUsers() { @@ -79,5 +85,14 @@ export function createUserStore(db: Database.Database): UserStore { const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); 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; + }, }; } diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts new file mode 100644 index 0000000..63b0c92 --- /dev/null +++ b/src/web/api/users.test.ts @@ -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); + }); +}); diff --git a/src/web/api/users.ts b/src/web/api/users.ts new file mode 100644 index 0000000..b837e77 --- /dev/null +++ b/src/web/api/users.ts @@ -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; +} diff --git a/src/web/server.ts b/src/web/server.ts index d7ad9e8..1424f46 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -15,6 +15,7 @@ import { createMusicRouter } from "./api/music.js"; import { createPlayerRouter } from "./api/player.js"; import { createAuthRouter } from "./api/auth.js"; import { createSessionRouter } from "./api/session.js"; +import { createUsersRouter } from "./api/users.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; @@ -100,6 +101,7 @@ 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, logger)); // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) {