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,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { deleteUser, updatePassword } from '@/lib/users'
|
||||
import { getSession } from '@/lib/session'
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Prevent admins from deleting themselves
|
||||
if (id === session.userId) {
|
||||
return NextResponse.json({ error: 'You cannot delete your own account' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUser(id)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
let body: { password?: string }
|
||||
try {
|
||||
body = (await request.json()) as { password?: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const password = String(body.password ?? '')
|
||||
|
||||
try {
|
||||
await updatePassword(id, password)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { listUsers, createUser } from '@/lib/users'
|
||||
import { getSession } from '@/lib/session'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const users = await listUsers()
|
||||
return NextResponse.json(users)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
let body: { username?: string; password?: string; role?: string }
|
||||
try {
|
||||
body = (await request.json()) as { username?: string; password?: string; role?: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const username = String(body.username ?? '').trim()
|
||||
const password = String(body.password ?? '')
|
||||
const role = body.role === 'admin' ? 'admin' : ('user' as const)
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await createUser(username, password, role)
|
||||
return NextResponse.json(user, { status: 201 })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 409 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authenticateUser, seedAdminIfEmpty } from '@/lib/users'
|
||||
import { createSession } from '@/lib/session'
|
||||
|
||||
// ── In-memory rate limiter (single-instance, resets on restart) ──────────────
|
||||
interface RateLimitEntry {
|
||||
attempts: number
|
||||
lockedUntil: number
|
||||
}
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>()
|
||||
const MAX_ATTEMPTS = 5
|
||||
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
return (
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
|
||||
req.headers.get('x-real-ip') ??
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
function checkRateLimit(ip: string): { allowed: boolean; retryAfterSecs?: number } {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry) return { allowed: true }
|
||||
if (entry.lockedUntil > now) {
|
||||
return { allowed: false, retryAfterSecs: Math.ceil((entry.lockedUntil - now) / 1000) }
|
||||
}
|
||||
// Lock has expired — reset
|
||||
rateLimitMap.delete(ip)
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
function recordFailedAttempt(ip: string): void {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip) ?? { attempts: 0, lockedUntil: 0 }
|
||||
entry.attempts++
|
||||
if (entry.attempts >= MAX_ATTEMPTS) {
|
||||
entry.lockedUntil = now + LOCKOUT_MS
|
||||
entry.attempts = 0
|
||||
}
|
||||
rateLimitMap.set(ip, entry)
|
||||
}
|
||||
|
||||
function clearAttempts(ip: string): void {
|
||||
rateLimitMap.delete(ip)
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
export async function POST(request: Request) {
|
||||
const ip = getClientIp(request)
|
||||
const { allowed, retryAfterSecs } = checkRateLimit(ip)
|
||||
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: `Too many attempts. Try again in ${retryAfterSecs} seconds.` },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterSecs) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
let body: { username?: string; password?: string }
|
||||
try {
|
||||
body = (await request.json()) as { username?: string; password?: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const username = String(body.username ?? '').trim()
|
||||
const password = String(body.password ?? '')
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Seed admin on first login attempt if no users exist yet
|
||||
await seedAdminIfEmpty()
|
||||
|
||||
const user = await authenticateUser(username, password)
|
||||
if (!user) {
|
||||
recordFailedAttempt(ip)
|
||||
// Return generic error — don't reveal whether the username exists
|
||||
return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 })
|
||||
}
|
||||
|
||||
clearAttempts(ip)
|
||||
await createSession({ userId: user.id, username: user.username, role: user.role })
|
||||
return NextResponse.json({ ok: true, username: user.username, role: user.role })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { deleteSession } from '@/lib/session'
|
||||
|
||||
export async function POST() {
|
||||
await deleteSession()
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readMeta } from '@/lib/store'
|
||||
import { getSession } from '@/lib/session'
|
||||
|
||||
export async function GET() {
|
||||
// Belt-and-suspenders auth check (middleware handles the primary check)
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const records = await readMeta()
|
||||
return NextResponse.json(records)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { addFile } from '@/lib/store'
|
||||
import { getSession } from '@/lib/session'
|
||||
import type { FileRecord } from '@/lib/store'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.backerup', '.json']
|
||||
const MAX_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Belt-and-suspenders auth check (middleware handles the primary check)
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
|
||||
Reference in New Issue
Block a user