From 34523cb00f11f40c8677cb9f15d8c2efe214ae76 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:29:39 +0800 Subject: [PATCH] feat(auth): add UserStore with bcryptjs password hashing Co-Authored-By: Claude Sonnet 4.6 --- src/data/users.test.ts | 56 ++++++++++++++++++++++++++++ src/data/users.ts | 84 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/data/users.test.ts create mode 100644 src/data/users.ts diff --git a/src/data/users.test.ts b/src/data/users.test.ts new file mode 100644 index 0000000..0bbd30d --- /dev/null +++ b/src/data/users.test.ts @@ -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); + }); +}); diff --git a/src/data/users.ts b/src/data/users.ts new file mode 100644 index 0000000..8499ccf --- /dev/null +++ b/src/data/users.ts @@ -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; + findByUsername(username: string): UserRow | null; + findById(id: string): UserRow | null; + verifyPassword(plain: string, hash: string): Promise; + changePassword(userId: string, newPassword: string): Promise; +} + +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); + }, + }; +}