1
0

feat: argon2 passwords and sqlite-backed sessions

This commit is contained in:
2026-08-29 15:11:15 +02:00
parent dff7b4826d
commit f48093319b
2 changed files with 116 additions and 0 deletions

66
server/src/auth.ts Normal file
View File

@@ -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<string> {
return argon2.hash(password)
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
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)
}

50
server/test/auth.test.ts Normal file
View File

@@ -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()
})
})