diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..10d6802 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { prisma } from '@/lib/prisma' +import { generateSessionToken, createSession } from '@/lib/session' + +const loginAttempts = new Map() +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +function isRateLimited(ip: string): boolean { + const now = Date.now() + const attempts = (loginAttempts.get(ip) ?? []).filter((t) => now - t < WINDOW_MS) + attempts.push(now) + loginAttempts.set(ip, attempts) + return attempts.length > MAX_ATTEMPTS +} + +export async function POST(request: NextRequest) { + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + + if (isRateLimited(ip)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + } + + let email: string, password: string + try { + const body = await request.json() + email = (body.email ?? '').trim() + password = body.password ?? '' + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ) + } + + const user = await prisma.user.findUnique({ where: { email } }) + const validPassword = + user != null && (await bcrypt.compare(password, user.pwd_hash)) + + if (!user || !validPassword) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const token = generateSessionToken() + await createSession(user.id, token) + + const response = NextResponse.json({ success: true }) + response.cookies.set('ops_session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 60 * 60 * 24, + path: '/', + }) + return response +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..2b7edfa --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { invalidateSession } from '@/lib/session' + +export async function POST(_request: NextRequest) { + const cookieStore = await cookies() + const token = cookieStore.get('ops_session')?.value + + if (token) { + await invalidateSession(token) + } + + const response = NextResponse.json({ success: true }) + response.cookies.delete('ops_session') + return response +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..1ebb739 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + + if (res.ok) { + router.push('/') + router.refresh() + } else { + const data = await res.json() + setError(data.error ?? 'Login failed') + } + } catch { + setError('Network error, please try again') + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Ops Dashboard +

+ +
+
+ + setEmail(e.target.value)} + className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400" + placeholder="admin@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400" + placeholder="••••••••" + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..ab6aceb --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,52 @@ +import 'server-only' +import { cookies } from 'next/headers' +import { createHash, randomBytes } from 'crypto' +import { prisma } from './prisma' + +const COOKIE_NAME = 'ops_session' +const SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +export function generateSessionToken(): string { + return randomBytes(32).toString('hex') +} + +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +export async function createSession(userId: string, token: string): Promise { + const expiresAt = new Date(Date.now() + SESSION_TTL_MS) + await prisma.session.create({ + data: { + user_id: userId, + token_hash: hashToken(token), + expires_at: expiresAt, + }, + }) +} + +export async function getCurrentUser() { + const cookieStore = await cookies() + const token = cookieStore.get(COOKIE_NAME)?.value + if (!token) return null + + const session = await prisma.session.findUnique({ + where: { token_hash: hashToken(token) }, + include: { user: true }, + }) + + if (!session) return null + + if (session.expires_at < new Date()) { + await prisma.session.delete({ where: { id: session.id } }) + return null + } + + return session.user +} + +export async function invalidateSession(token: string): Promise { + await prisma.session.deleteMany({ + where: { token_hash: hashToken(token) }, + }) +} diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..e2b24d3 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' + +const PUBLIC_PATHS = ['/login'] + +export default function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p)) + const hasSession = request.cookies.has('ops_session') + + if (!isPublic && !hasSession) { + return NextResponse.redirect(new URL('/login', request.url)) + } + + if (isPublic && hasSession) { + return NextResponse.redirect(new URL('/', request.url)) + } + + return NextResponse.next() +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|.*\\.(?:png|ico|svg)$).*)'], +}