mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
feat: implement user authentication and admin management
- 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.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user