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 { 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();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user