mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
The by-id user-management handlers use findById, which has no role filter, so supplying the synthetic GUEST_USER_ID let an admin delete, re-role, reset-password, and read/write permissions on the shared guest principal (privilege-escalation / DoS / credential-login holes). - web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID (DELETE, reset-password, role, GET/PUT permissions). - data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and deleteUserIfNotLastAdmin return "not_found" for any role=guest row. - web/api/session.ts: wrap POST /guest createSession in try/catch so a missing guest row yields 503 instead of an unhandled 500. - Tests: data-layer guest-protection + users-router 404 by-id guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type Database from "better-sqlite3";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const BCRYPT_ROUNDS = 12;
|
|
|
|
export type UserRole = "admin" | "member" | "guest";
|
|
|
|
/** Reserved synthetic principal for login-less guest sessions. The username is
|
|
* non-ASCII so it can never collide with an API-created account (which is
|
|
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
|
|
export const GUEST_USER_ID = "__guest__";
|
|
export const GUEST_USERNAME = "游客";
|
|
|
|
export interface UserRow {
|
|
id: string;
|
|
username: string;
|
|
passwordHash: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
role: UserRole;
|
|
}
|
|
|
|
export interface UserStore {
|
|
countUsers(): number;
|
|
countAdmins(): number;
|
|
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
|
|
createFirstUser(username: string, password: string): Promise<UserRow | null>;
|
|
findByUsername(username: string): UserRow | null;
|
|
findById(id: string): UserRow | null;
|
|
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
|
changePassword(userId: string, newPassword: string): Promise<void>;
|
|
setRole(userId: string, role: UserRole): boolean;
|
|
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
|
|
deleteUser(id: string): boolean;
|
|
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
|
|
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
|
}
|
|
|
|
export class UsernameTakenError extends Error {
|
|
constructor(username: string) {
|
|
super(`username taken: ${username}`);
|
|
this.name = "UsernameTakenError";
|
|
}
|
|
}
|
|
|
|
export function createUserStore(db: Database.Database): UserStore {
|
|
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
|
|
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
|
|
const insertStmt = db.prepare(
|
|
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
|
|
);
|
|
const findByUsernameStmt = db.prepare(
|
|
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
|
|
);
|
|
const findByIdStmt = db.prepare(
|
|
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
|
|
);
|
|
const updatePasswordStmt = db.prepare(
|
|
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
|
);
|
|
const updateRoleStmt = db.prepare(
|
|
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
|
|
);
|
|
const listUsersStmt = db.prepare(
|
|
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
|
|
);
|
|
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
|
|
|
return {
|
|
countUsers() {
|
|
return (countStmt.get() as { n: number }).n;
|
|
},
|
|
|
|
countAdmins() {
|
|
return (countAdminsStmt.get() as { n: number }).n;
|
|
},
|
|
|
|
async createUser(username, password, role) {
|
|
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
const id = randomUUID();
|
|
const now = Date.now();
|
|
try {
|
|
insertStmt.run(id, username, hash, now, now, role);
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
throw new UsernameTakenError(username);
|
|
}
|
|
throw err;
|
|
}
|
|
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
|
|
},
|
|
|
|
async createFirstUser(username, password) {
|
|
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
const id = randomUUID();
|
|
const now = Date.now();
|
|
const run = db.transaction(() => {
|
|
const count = (countStmt.get() as { n: number }).n;
|
|
if (count !== 0) return null;
|
|
try {
|
|
insertStmt.run(id, username, hash, now, now, "admin");
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
|
|
});
|
|
return run();
|
|
},
|
|
|
|
findByUsername(username) {
|
|
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
|
|
},
|
|
|
|
findById(id) {
|
|
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
|
|
},
|
|
|
|
verifyPassword(plain, hash) {
|
|
return bcrypt.compare(plain, hash);
|
|
},
|
|
|
|
async changePassword(userId, newPassword) {
|
|
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
|
updatePasswordStmt.run(hash, Date.now(), userId);
|
|
},
|
|
|
|
setRole(userId, role) {
|
|
const result = updateRoleStmt.run(role, Date.now(), userId);
|
|
return result.changes > 0;
|
|
},
|
|
|
|
setRoleIfNotLastAdmin(id, newRole) {
|
|
const tx = db.transaction(() => {
|
|
const row = findByIdStmt.get(id) as UserRow | undefined;
|
|
if (!row) return "not_found" as const;
|
|
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
|
if (row.role === newRole) return "ok" as const; // no-op
|
|
if (row.role === "admin" && newRole === "member") {
|
|
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
|
if (adminCount <= 1) return "would_orphan" as const;
|
|
}
|
|
updateRoleStmt.run(newRole, Date.now(), id);
|
|
return "ok" as const;
|
|
});
|
|
return tx();
|
|
},
|
|
|
|
listUsers() {
|
|
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
|
},
|
|
|
|
deleteUser(id) {
|
|
const result = deleteUserStmt.run(id);
|
|
return result.changes > 0;
|
|
},
|
|
|
|
deleteUserIfNotLastAdmin(id) {
|
|
const tx = db.transaction(() => {
|
|
const row = findByIdStmt.get(id) as UserRow | undefined;
|
|
if (!row) return "not_found" as const;
|
|
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
|
if (row.role === "admin") {
|
|
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
|
if (adminCount <= 1) return "would_orphan" as const;
|
|
}
|
|
deleteUserStmt.run(id);
|
|
return "ok" as const;
|
|
});
|
|
return tx();
|
|
},
|
|
};
|
|
}
|