diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..d82e18d --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,66 @@ +import crypto from 'node:crypto' +import argon2 from 'argon2' +import type { DB } from './db.js' + +export interface UserRow { + id: number + username: string + password_hash: string + is_admin: number + created_at: string +} + +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +export function hashPassword(password: string): Promise { + return argon2.hash(password) +} + +export function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password) +} + +export function createUser(db: DB, username: string, passwordHash: string, isAdmin: boolean): UserRow { + const info = db + .prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)') + .run(username, passwordHash, isAdmin ? 1 : 0) + return db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid) as UserRow +} + +export function getUserByUsername(db: DB, username: string): UserRow | undefined { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined +} + +export function getUserById(db: DB, id: number): UserRow | undefined { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined +} + +export function createSession(db: DB, userId: number): string { + const token = crypto.randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString() + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + userId, + expiresAt + ) + return token +} + +export function getUserBySession(db: DB, token: string, now: () => number = Date.now): UserRow | null { + const row = db + .prepare( + `SELECT u.*, s.expires_at FROM sessions s + JOIN users u ON u.id = s.user_id WHERE s.token = ?` + ) + .get(token) as (UserRow & { expires_at: string }) | undefined + if (!row) return null + if (new Date(row.expires_at).getTime() < now()) { + deleteSession(db, token) + return null + } + return row +} + +export function deleteSession(db: DB, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token) +} diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts new file mode 100644 index 0000000..56364f7 --- /dev/null +++ b/server/test/auth.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' +import { + hashPassword, + verifyPassword, + createUser, + createSession, + getUserBySession, + deleteSession, +} from '../src/auth.js' + +describe('passwords', () => { + it('hashes and verifies', async () => { + const hash = await hashPassword('correct horse battery staple') + expect(hash).not.toContain('correct') + expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true) + expect(await verifyPassword(hash, 'wrong')).toBe(false) + }) +}) + +describe('sessions', () => { + it('creates a user and resolves a valid session, rejects expired', async () => { + const db = openDatabase(':memory:') + const user = createUser(db, 'sam', await hashPassword('password123'), true) + expect(user.is_admin).toBe(1) + + const token = await createSession(db, user.id) + const resolved = getUserBySession(db, token, () => Date.now()) + expect(resolved?.username).toBe('sam') + + const expiredToken = await createSession(db, user.id) + db.prepare('UPDATE sessions SET expires_at = ? WHERE token = ?').run( + '2000-01-01T00:00:00.000Z', + expiredToken + ) + expect(getUserBySession(db, expiredToken, () => Date.now())).toBeNull() + expect( + db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number } + ).toEqual({ n: 1 }) + + deleteSession(db, token) + expect(getUserBySession(db, token, () => Date.now())).toBeNull() + }) + + it('rejects duplicate usernames', async () => { + const db = openDatabase(':memory:') + createUser(db, 'sam', 'hash1', false) + expect(() => createUser(db, 'sam', 'hash2', false)).toThrow() + }) +})