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:
saopig1andClaude Sonnet 4.6 committed 2026-05-27 14:41:58 +08:00
1 parent f46b37192f
commit 175b8e6065
5 files changed
+257

No files matched your search

+19
View File
@@ -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);
});
});
+15
View File
@@ -19,6 +19,8 @@ export interface UserStore {
findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>;
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;
},
};
}
+138
View File
@@ -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);
});
});
+83
View File
@@ -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;
}
+2
View File
@@ -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) {