mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat(auth): add UserStore with bcryptjs password hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ea1a64c59
commit
34523cb00f
2 files changed
+140
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js";
|
||||
|
||||
describe("UserStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("countUsers is 0 on a fresh db", () => {
|
||||
expect(users.countUsers()).toBe(0);
|
||||
});
|
||||
|
||||
it("createUser stores the user and bumps countUsers", async () => {
|
||||
const u = await users.createUser("alice", "pw-hunter2");
|
||||
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(u.username).toBe("alice");
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("findByUsername is case-insensitive and returns null for missing", async () => {
|
||||
await users.createUser("Alice", "pw");
|
||||
expect(users.findByUsername("ALICE")).not.toBeNull();
|
||||
expect(users.findByUsername("alice")).not.toBeNull();
|
||||
expect(users.findByUsername("bob")).toBeNull();
|
||||
});
|
||||
|
||||
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
|
||||
await users.createUser("Alice", "pw");
|
||||
await expect(users.createUser("alice", "pw2")).rejects.toBeInstanceOf(UsernameTakenError);
|
||||
});
|
||||
|
||||
it("verifyPassword accepts correct password and rejects wrong one", async () => {
|
||||
await users.createUser("alice", "correct-horse-battery-staple");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(row).not.toBeNull();
|
||||
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
|
||||
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
|
||||
});
|
||||
|
||||
it("changePassword updates the hash so the old password no longer verifies", async () => {
|
||||
const u = await users.createUser("alice", "old");
|
||||
await users.changePassword(u.id, "new");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false);
|
||||
expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface UserStore {
|
||||
countUsers(): number;
|
||||
createUser(username: string, password: string): Promise<UserRow>;
|
||||
findByUsername(username: string): UserRow | null;
|
||||
findById(id: string): UserRow | null;
|
||||
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
||||
changePassword(userId: string, newPassword: string): Promise<void>;
|
||||
}
|
||||
|
||||
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");
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const findByUsernameStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE username = ? COLLATE NOCASE"
|
||||
);
|
||||
const findByIdStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE id = ?"
|
||||
);
|
||||
const updatePasswordStmt = db.prepare(
|
||||
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
return {
|
||||
countUsers() {
|
||||
return (countStmt.get() as { n: number }).n;
|
||||
},
|
||||
|
||||
async createUser(username, password) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
try {
|
||||
insertStmt.run(id, username, hash, now, now);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (msg.includes("UNIQUE") && msg.includes("users.username")) {
|
||||
throw new UsernameTakenError(username);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now };
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user