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,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'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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