mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
import crypto from 'crypto'
|
|
import { hashPassword, verifyPassword } from './auth'
|
|
import { getDb, ensureReady } from './db'
|
|
|
|
export type Role = 'admin' | 'user'
|
|
|
|
export interface User {
|
|
id: string
|
|
username: string
|
|
passwordHash: string
|
|
salt: string
|
|
role: Role
|
|
createdAt: string
|
|
}
|
|
|
|
/** User fields safe to expose to the client (no credentials). */
|
|
export type PublicUser = Omit<User, 'passwordHash' | 'salt'>
|
|
|
|
type DbRow = Record<string, unknown>
|
|
|
|
function rowToUser(row: DbRow): User {
|
|
return {
|
|
id: row.id as string,
|
|
username: row.username as string,
|
|
passwordHash: row.password_hash as string,
|
|
salt: row.salt as string,
|
|
role: row.role as Role,
|
|
createdAt: row.created_at as string,
|
|
}
|
|
}
|
|
|
|
function toPublic(user: User): PublicUser {
|
|
const { passwordHash: _, salt: __, ...pub } = user
|
|
return pub
|
|
}
|
|
|
|
export async function listUsers(): Promise<PublicUser[]> {
|
|
await ensureReady()
|
|
const { rows } = await getDb().execute(
|
|
'SELECT * FROM users ORDER BY created_at ASC',
|
|
)
|
|
return rows.map((r) => toPublic(rowToUser(r)))
|
|
}
|
|
|
|
export async function findUserByUsername(username: string): Promise<User | undefined> {
|
|
await ensureReady()
|
|
const { rows } = await getDb().execute({
|
|
sql: 'SELECT * FROM users WHERE username = ? COLLATE NOCASE',
|
|
args: [username],
|
|
})
|
|
return rows[0] ? rowToUser(rows[0]) : undefined
|
|
}
|
|
|
|
export async function findUserById(id: string): Promise<User | undefined> {
|
|
await ensureReady()
|
|
const { rows } = await getDb().execute({
|
|
sql: 'SELECT * FROM users WHERE id = ?',
|
|
args: [id],
|
|
})
|
|
return rows[0] ? rowToUser(rows[0]) : undefined
|
|
}
|
|
|
|
export async function createUser(
|
|
username: string,
|
|
password: string,
|
|
role: Role,
|
|
): Promise<PublicUser> {
|
|
await ensureReady()
|
|
if (!username.trim()) throw new Error('Username cannot be empty')
|
|
if (password.length < 8) throw new Error('Password must be at least 8 characters')
|
|
|
|
const { hash, salt } = await hashPassword(password)
|
|
const user: User = {
|
|
id: crypto.randomUUID(),
|
|
username: username.trim(),
|
|
passwordHash: hash,
|
|
salt,
|
|
role,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
|
|
try {
|
|
await getDb().execute({
|
|
sql: `INSERT INTO users (id,username,password_hash,salt,role,created_at)
|
|
VALUES (?,?,?,?,?,?)`,
|
|
args: [user.id, user.username, user.passwordHash, user.salt, user.role, user.createdAt],
|
|
})
|
|
} catch (e: unknown) {
|
|
if (String(e).includes('UNIQUE')) {
|
|
throw new Error(`Username "${username}" is already taken`)
|
|
}
|
|
throw e
|
|
}
|
|
|
|
return toPublic(user)
|
|
}
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
await ensureReady()
|
|
const db = getDb()
|
|
|
|
const { rows: userRows } = await db.execute({
|
|
sql: 'SELECT * FROM users WHERE id = ?',
|
|
args: [id],
|
|
})
|
|
if (!userRows[0]) throw new Error('User not found')
|
|
const user = rowToUser(userRows[0])
|
|
|
|
if (user.role === 'admin') {
|
|
const { rows: adminRows } = await db.execute(
|
|
"SELECT COUNT(*) as cnt FROM users WHERE role = 'admin'",
|
|
)
|
|
if ((adminRows[0].cnt as number) <= 1) {
|
|
throw new Error('Cannot delete the last admin account')
|
|
}
|
|
}
|
|
|
|
await db.execute({ sql: 'DELETE FROM users WHERE id = ?', args: [id] })
|
|
}
|
|
|
|
export async function updatePassword(id: string, newPassword: string): Promise<void> {
|
|
await ensureReady()
|
|
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
|
|
|
|
const { rows } = await getDb().execute({
|
|
sql: 'SELECT id FROM users WHERE id = ?',
|
|
args: [id],
|
|
})
|
|
if (!rows[0]) throw new Error('User not found')
|
|
|
|
const { hash, salt } = await hashPassword(newPassword)
|
|
await getDb().execute({
|
|
sql: 'UPDATE users SET password_hash = ?, salt = ? WHERE id = ?',
|
|
args: [hash, salt, id],
|
|
})
|
|
}
|
|
|
|
export async function authenticateUser(username: string, password: string): Promise<User | null> {
|
|
const user = await findUserByUsername(username)
|
|
if (!user) {
|
|
await hashPassword(password) // constant-time dummy to prevent enumeration
|
|
return null
|
|
}
|
|
const valid = await verifyPassword(password, user.passwordHash, user.salt)
|
|
return valid ? user : null
|
|
}
|
|
|
|
export async function countUsers(): Promise<number> {
|
|
await ensureReady()
|
|
const { rows } = await getDb().execute('SELECT COUNT(*) as cnt FROM users')
|
|
return rows[0].cnt as number
|
|
}
|
|
|
|
/**
|
|
* Ensures the admin user defined by ADMIN_USERNAME / ADMIN_PASSWORD env vars
|
|
* exists and has the correct password. Runs on every login attempt so that
|
|
* changing the env var (and restarting the container) takes effect immediately.
|
|
* Does nothing when ADMIN_PASSWORD is not set.
|
|
*/
|
|
export async function seedAdminIfEmpty(): Promise<void> {
|
|
const password = process.env.ADMIN_PASSWORD
|
|
if (!password) return
|
|
|
|
const username = process.env.ADMIN_USERNAME ?? 'admin'
|
|
|
|
try {
|
|
const existing = await findUserByUsername(username)
|
|
if (!existing) {
|
|
await createUser(username, password, 'admin')
|
|
console.log(`[auth] Created admin user: "${username}"`)
|
|
} else {
|
|
// Always sync the password so env-var changes take effect after restart
|
|
const matches = await verifyPassword(password, existing.passwordHash, existing.salt)
|
|
if (!matches) {
|
|
await updatePassword(existing.id, password)
|
|
console.log(`[auth] Synced password for admin user: "${username}"`)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[auth] Failed to sync admin user:', e)
|
|
}
|
|
}
|
|
|