mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
0d58fafe4e
- Add user management API endpoints for listing and creating users. - Implement login and logout functionality with rate limiting. - Create UI components for login form, logout button, and animated elements. - Add session management with JWT and secure cookie handling. - Seed initial admin user if no users exist. - Introduce utility functions for password hashing and user authentication. - Set up proxy middleware for route protection and security headers. - Create a JSON file for user data storage. - Add links configuration for external resources.
141 lines
4.2 KiB
TypeScript
141 lines
4.2 KiB
TypeScript
import path from 'path'
|
|
import fs from 'fs/promises'
|
|
import crypto from 'crypto'
|
|
import { hashPassword, verifyPassword } from './auth'
|
|
|
|
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'>
|
|
|
|
const DATA_DIR = path.join(process.cwd(), 'data')
|
|
const USERS_FILE = path.join(DATA_DIR, 'users.json')
|
|
|
|
async function readUsers(): Promise<User[]> {
|
|
try {
|
|
const text = await fs.readFile(USERS_FILE, 'utf-8')
|
|
return JSON.parse(text) as User[]
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function writeUsers(users: User[]): Promise<void> {
|
|
await fs.mkdir(DATA_DIR, { recursive: true })
|
|
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
|
|
}
|
|
|
|
function toPublic(user: User): PublicUser {
|
|
const { passwordHash: _, salt: __, ...pub } = user
|
|
return pub
|
|
}
|
|
|
|
export async function listUsers(): Promise<PublicUser[]> {
|
|
const users = await readUsers()
|
|
return users.map(toPublic)
|
|
}
|
|
|
|
export async function findUserByUsername(username: string): Promise<User | undefined> {
|
|
const users = await readUsers()
|
|
return users.find((u) => u.username.toLowerCase() === username.toLowerCase())
|
|
}
|
|
|
|
export async function findUserById(id: string): Promise<User | undefined> {
|
|
const users = await readUsers()
|
|
return users.find((u) => u.id === id)
|
|
}
|
|
|
|
export async function createUser(
|
|
username: string,
|
|
password: string,
|
|
role: Role,
|
|
): Promise<PublicUser> {
|
|
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 users = await readUsers()
|
|
if (users.some((u) => u.username.toLowerCase() === username.toLowerCase())) {
|
|
throw new Error(`Username "${username}" is already taken`)
|
|
}
|
|
|
|
const { hash, salt } = await hashPassword(password)
|
|
const user: User = {
|
|
id: crypto.randomUUID(),
|
|
username: username.trim(),
|
|
passwordHash: hash,
|
|
salt,
|
|
role,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
users.push(user)
|
|
await writeUsers(users)
|
|
return toPublic(user)
|
|
}
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
const users = await readUsers()
|
|
const target = users.find((u) => u.id === id)
|
|
if (!target) throw new Error('User not found')
|
|
|
|
const remaining = users.filter((u) => u.id !== id)
|
|
const remainingAdmins = remaining.filter((u) => u.role === 'admin')
|
|
if (target.role === 'admin' && remainingAdmins.length === 0) {
|
|
throw new Error('Cannot delete the last admin account')
|
|
}
|
|
await writeUsers(remaining)
|
|
}
|
|
|
|
export async function updatePassword(id: string, newPassword: string): Promise<void> {
|
|
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
|
|
const users = await readUsers()
|
|
const idx = users.findIndex((u) => u.id === id)
|
|
if (idx === -1) throw new Error('User not found')
|
|
const { hash, salt } = await hashPassword(newPassword)
|
|
users[idx].passwordHash = hash
|
|
users[idx].salt = salt
|
|
await writeUsers(users)
|
|
}
|
|
|
|
export async function authenticateUser(username: string, password: string): Promise<User | null> {
|
|
const user = await findUserByUsername(username)
|
|
if (!user) {
|
|
// Run a dummy derivation to keep constant time even when user not found
|
|
await hashPassword(password)
|
|
return null
|
|
}
|
|
const valid = await verifyPassword(password, user.passwordHash, user.salt)
|
|
return valid ? user : null
|
|
}
|
|
|
|
export async function countUsers(): Promise<number> {
|
|
const users = await readUsers()
|
|
return users.length
|
|
}
|
|
|
|
/**
|
|
* Seeds an initial admin user if no users exist yet.
|
|
* Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment.
|
|
*/
|
|
export async function seedAdminIfEmpty(): Promise<void> {
|
|
const count = await countUsers()
|
|
if (count > 0) return
|
|
const password = process.env.ADMIN_PASSWORD
|
|
if (!password) return
|
|
const username = process.env.ADMIN_USERNAME ?? 'admin'
|
|
try {
|
|
await createUser(username, password, 'admin')
|
|
console.log(`[auth] Seeded initial admin user: "${username}"`)
|
|
} catch (e) {
|
|
console.error('[auth] Failed to seed admin user:', e)
|
|
}
|
|
}
|