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:
parent
8569b506d2
commit
14d0b533af
78
src/app/admin/login/page.tsx
Normal file
78
src/app/admin/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/api/admin/change-password/route.ts
Normal file
45
src/app/api/admin/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
src/app/api/admin/login/route.ts
Normal file
41
src/app/api/admin/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/admin/logout/route.ts
Normal file
15
src/app/api/admin/logout/route.ts
Normal 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
68
src/lib/admin-session.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -1,9 +1,14 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { NextResponse } 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(.*)"]);
|
||||
|
||||
function isAdminRoute(pathname: string): boolean {
|
||||
return pathname.startsWith("/admin") || pathname.startsWith("/api/admin");
|
||||
}
|
||||
|
||||
function getSubdomain(req: NextRequest): string | null {
|
||||
const host = req.headers.get("host");
|
||||
if (!host) return null;
|
||||
@ -25,6 +30,22 @@ function getSubdomain(req: NextRequest): string | null {
|
||||
}
|
||||
|
||||
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)) {
|
||||
await auth.protect();
|
||||
}
|
||||
@ -41,4 +62,4 @@ export default clerkMiddleware(async (auth, req: NextRequest) => {
|
||||
|
||||
export const config = {
|
||||
matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"],
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user