feat: argon2 passwords and sqlite-backed sessions
This commit is contained in:
66
server/src/auth.ts
Normal file
66
server/src/auth.ts
Normal 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
50
server/test/auth.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user