feat(auth): add admin authentication with HMAC session cookies

- Create admin-session lib with sign/verify helpers using HMAC-SHA256
- Add admin login API that checks hardcoded super/admin credentials
  and DB-stored admin users with bcrypt password comparison
- Add admin logout API to clear session cookie
- Add change-password API for admin self-service password changes
- Create admin login page with Macedonian UI
- Update middleware to protect /admin/* and /api/admin/* routes
  with admin session check, bypassing Clerk auth
This commit is contained in:
dimitar 2026-07-29 18:55:46 +02:00
parent 8569b506d2
commit 14d0b533af
6 changed files with 269 additions and 1 deletions

View File

@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Најавата не успеа");
}
router.push("/admin/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Најавата не успеа");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-stone-100">
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow-sm">
<h1 className="mb-6 text-center text-xl font-semibold text-stone-900">Администрација</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-stone-700">
Корисничко име
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-stone-700">
Лозинка
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Најавување..." : "Најави се"}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { hash, compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function POST(req: NextRequest) {
try {
const session = await getAdminSession();
if (!session) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
if (session.username === "super") {
return NextResponse.json({ error: "SuperAdmin не може да ја промени лозинката преку овој метод" }, { status: 400 });
}
const { currentPassword, newPassword } = await req.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: "Тековната и новата лозинка се задолжителни" }, { status: 400 });
}
if (newPassword.length < 6) {
return NextResponse.json({ error: "Новата лозинка мора да има најмалку 6 карактери" }, { status: 400 });
}
const admin = await prisma.adminUser.findUnique({ where: { username: session.username } });
if (!admin) {
return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 });
}
const valid = await compare(currentPassword, admin.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Тековната лозинка е неточна" }, { status: 401 });
}
const passwordHash = await hash(newPassword, 12);
await prisma.adminUser.update({
where: { username: session.username },
data: { passwordHash },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Change password error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { createAdminSession, cookieOptions } from "@/lib/admin-session";
const SUPER_USERNAME = "super";
const SUPER_PASSWORD = "admin";
export async function POST(req: NextRequest) {
try {
const { username, password } = await req.json();
if (!username || !password) {
return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 });
}
if (username === SUPER_USERNAME && password === SUPER_PASSWORD) {
const session = createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;
}
const admin = await prisma.adminUser.findUnique({ where: { username } });
if (!admin) {
return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 });
}
const valid = await compare(password, admin.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 });
}
const session = createAdminSession({ username: admin.username, role: admin.role });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;
} catch (error) {
console.error("Admin login error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ success: true });
res.cookies.set({
name: "admin_session",
value: "",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/admin",
maxAge: 0,
});
return res;
}

68
src/lib/admin-session.ts Normal file
View File

@ -0,0 +1,68 @@
import { cookies } from "next/headers";
import { createHmac, timingSafeEqual } from "crypto";
const COOKIE_NAME = "admin_session";
const SEP = ".";
function getSecret(): string {
const secret = process.env.ADMIN_SESSION_SECRET;
if (!secret) throw new Error("ADMIN_SESSION_SECRET env var is not set");
return secret;
}
function sign(payload: string): string {
const hmac = createHmac("sha256", getSecret());
hmac.update(payload);
return hmac.digest("base64url");
}
export interface AdminSession {
username: string;
role: "SUPER_ADMIN" | "ADMIN";
}
export function createAdminSession(data: AdminSession): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url");
return payload + SEP + sign(payload);
}
export function verifyAdminSession(token: string): AdminSession | null {
const sepIdx = token.lastIndexOf(SEP);
if (sepIdx === -1) return null;
const payload = token.slice(0, sepIdx);
const sig = token.slice(sepIdx + 1);
const expectedSig = sign(payload);
const sigBuf = Buffer.from(sig);
const expectedBuf = Buffer.from(expectedSig);
if (sigBuf.length !== expectedBuf.length) return null;
try {
if (!timingSafeEqual(sigBuf, expectedBuf)) return null;
} catch {
return null;
}
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
} catch {
return null;
}
}
export async function getAdminSession(): Promise<AdminSession | null> {
const store = await cookies();
const token = store.get(COOKIE_NAME)?.value;
if (!token) return null;
return verifyAdminSession(token);
}
export const COOKIE_NAME_ADMIN = COOKIE_NAME;
export function cookieOptions(value: string) {
return {
name: COOKIE_NAME,
value,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/admin",
};
}

View File

@ -1,9 +1,14 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { verifyAdminSession, COOKIE_NAME_ADMIN } from "@/lib/admin-session";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]); const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]);
function isAdminRoute(pathname: string): boolean {
return pathname.startsWith("/admin") || pathname.startsWith("/api/admin");
}
function getSubdomain(req: NextRequest): string | null { function getSubdomain(req: NextRequest): string | null {
const host = req.headers.get("host"); const host = req.headers.get("host");
if (!host) return null; if (!host) return null;
@ -25,6 +30,22 @@ function getSubdomain(req: NextRequest): string | null {
} }
export default clerkMiddleware(async (auth, req: NextRequest) => { export default clerkMiddleware(async (auth, req: NextRequest) => {
if (isAdminRoute(req.nextUrl.pathname)) {
if (req.nextUrl.pathname === "/admin/login") {
return NextResponse.next();
}
const token = req.cookies.get(COOKIE_NAME_ADMIN)?.value;
if (!token || !verifyAdminSession(token)) {
if (req.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
return NextResponse.redirect(new URL("/admin/login", req.url));
}
return NextResponse.next();
}
if (isProtectedRoute(req)) { if (isProtectedRoute(req)) {
await auth.protect(); await auth.protect();
} }