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:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+36
View File
@@ -0,0 +1,36 @@
import crypto from 'crypto'
// scrypt params: N=16384 gives ~100ms on modern hardware — deliberate slowdown
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const KEY_LEN = 64
/** Hashes a password using scrypt. Returns the hex hash and a random hex salt. */
export async function hashPassword(password: string): Promise<{ hash: string; salt: string }> {
const salt = crypto.randomBytes(16).toString('hex')
const hash = await deriveKey(password, salt)
return { hash, salt }
}
/** Constant-time comparison to prevent timing attacks. */
export async function verifyPassword(
password: string,
hash: string,
salt: string,
): Promise<boolean> {
const derived = await deriveKey(password, salt)
const a = Buffer.from(derived, 'hex')
const b = Buffer.from(hash, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
function deriveKey(password: string, salt: string): Promise<string> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
if (err) reject(err)
else resolve(key.toString('hex'))
})
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* ─────────────────────────────────────────────────────────────
* LINK CONFIGURATION
* Edit this file to update every link across the entire site.
* ─────────────────────────────────────────────────────────────
*/
export const LINKS = {
// ── GitHub ────────────────────────────────────────────────
/** Repository root — used in the nav, footer, and call-to-action buttons */
github: "https://github.com/evan-buss/backerup",
/** Releases / changelog page */
githubReleases: "https://github.com/evan-buss/backerup/releases",
/** Issue tracker */
githubIssues: "https://github.com/evan-buss/backerup/issues",
/** GitHub Discussions forum */
githubDiscussions: "https://github.com/evan-buss/backerup/discussions",
/** CONTRIBUTING.md guide */
githubContributing: "https://github.com/evan-buss/backerup/blob/main/CONTRIBUTING.md",
// ── Documentation ─────────────────────────────────────────
/** Primary documentation URL (separate from GitHub if hosted elsewhere) */
docs: "https://github.com/evan-buss/backerup",
// ── Docker ────────────────────────────────────────────────
/** Full Docker image reference shown in install snippets */
dockerImage: "ghcr.io/evan-buss/backerup:latest",
/** Default UI port the container exposes */
uiPort: "8080",
} as const;
export type Links = typeof LINKS;
+77
View File
@@ -0,0 +1,77 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
export type Role = 'admin' | 'user'
export interface SessionPayload {
userId: string
username: string
role: Role
}
export const SESSION_COOKIE = 'backerup_session'
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
// Dev fallback — NOT safe for production (use AUTH_SECRET env var)
const DEV_FALLBACK_SECRET = 'dev-secret-NOT-for-production-change-me-32chars!!'
export function getSecretKey(): Uint8Array {
const secret = process.env.AUTH_SECRET
if (!secret) {
if (process.env.NODE_ENV !== 'production') {
return new TextEncoder().encode(DEV_FALLBACK_SECRET)
}
throw new Error('AUTH_SECRET environment variable is required in production')
}
if (secret.length < 32) {
throw new Error('AUTH_SECRET must be at least 32 characters long')
}
return new TextEncoder().encode(secret)
}
/** Creates and stores a session JWT in an httpOnly cookie. */
export async function createSession(payload: SessionPayload): Promise<void> {
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
const token = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresAt)
.sign(getSecretKey())
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt,
path: '/',
})
}
/** Reads and validates the current session cookie. Returns null if missing/invalid. */
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE)?.value
if (!token) return null
return verifyToken(token)
}
/** Deletes the session cookie. */
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(SESSION_COOKIE)
}
/** Verifies a JWT token string. Edge-runtime safe (uses jose). */
export async function verifyToken(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), { algorithms: ['HS256'] })
return {
userId: payload.userId as string,
username: payload.username as string,
role: payload.role as Role,
}
} catch {
return null
}
}
+140
View File
@@ -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)
}
}