A self-contained template: an Express backend with login, JWT, rate limiting and SPA serving, and a React 18 + Vite shell with the same tab registry and warm Material 3 design system the panel uses. No domain logic — routes and tabs are meant to be hung off it. node_modules and the frontend build stay out through the existing ignores.
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
// Copyright (c) 2026 Ivan Vasilev
|
|
// This source code is licensed under the MIT license found in the
|
|
// LICENSE file in the root directory of this source tree.
|
|
// Бэкенд админ-панели: логин, JWT-сессия, отдача собранного SPA.
|
|
// Никакой предметной логики здесь нет — это каркас, поверх которого
|
|
// навешиваются свои роуты под /api (они автоматически за requireAuth).
|
|
import express, { Request, Response, NextFunction } from "express";
|
|
import path from "path";
|
|
import crypto from "crypto";
|
|
|
|
const app = express();
|
|
const PORT = Number(process.env.PORT) || 8080;
|
|
|
|
const ADMIN_USER = process.env.ADMIN_USER ?? "admin";
|
|
const ADMIN_PASS = process.env.ADMIN_PASS ?? "";
|
|
// Без заданного секрета токены живут только до рестарта процесса — для дева
|
|
// это удобно, в проде JWT_SECRET обязателен (иначе сессии рвутся при рестарте).
|
|
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
|
|
|
// Каталог со сборкой фронта (vite build → frontend/dist).
|
|
const STATIC_DIR = process.env.STATIC_DIR ?? path.join(__dirname, "..", "frontend", "dist");
|
|
|
|
const TOKEN_TTL = 24 * 60 * 60; // сек
|
|
|
|
// Отозванные токены (logout). In-memory: рестарт процесса и так рвёт сессии,
|
|
// потому что при пустом JWT_SECRET секрет генерится заново.
|
|
const revoked = new Set<string>();
|
|
|
|
function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
|
|
|
|
function sign(user: string): string {
|
|
const h = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url");
|
|
const p = Buffer.from(JSON.stringify({
|
|
sub: user,
|
|
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
|
|
})).toString("base64url");
|
|
const s = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url");
|
|
return `${h}.${p}.${s}`;
|
|
}
|
|
|
|
interface Claims { sub?: string; exp: number; }
|
|
|
|
function verify(token: string): Claims | null {
|
|
try {
|
|
if (revoked.has(tokenSig(token))) return null;
|
|
const [h, p, s] = token.split(".");
|
|
if (!h || !p || !s) return null;
|
|
const expected = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest();
|
|
const actual = Buffer.from(s, "base64url");
|
|
if (actual.length !== expected.length) return null;
|
|
if (!crypto.timingSafeEqual(actual, expected)) return null;
|
|
const claims = JSON.parse(Buffer.from(p, "base64url").toString()) as Claims;
|
|
if (claims.exp <= Math.floor(Date.now() / 1000)) return null;
|
|
return claims;
|
|
} catch { return null; }
|
|
}
|
|
|
|
interface AuthRequest extends Request { claims?: Claims; }
|
|
|
|
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
const auth = (req.headers["authorization"] ?? "") as string;
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
const claims = token ? verify(token) : null;
|
|
if (!claims) { res.status(401).json({ error: "Unauthorized" }); return; }
|
|
(req as AuthRequest).claims = claims;
|
|
next();
|
|
}
|
|
|
|
// Ограничение перебора пароля: 5 неудачных попыток на IP за 15 минут.
|
|
const RATE_LIMIT = 5;
|
|
const RATE_WINDOW = 15 * 60 * 1000;
|
|
|
|
interface RateEntry { count: number; resetAt: number; }
|
|
const loginAttempts = new Map<string, RateEntry>();
|
|
|
|
function checkRate(ip: string): { ok: boolean; retryAfter?: number } {
|
|
const now = Date.now();
|
|
const entry = loginAttempts.get(ip);
|
|
if (!entry || entry.resetAt < now) {
|
|
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW });
|
|
return { ok: true };
|
|
}
|
|
if (entry.count >= RATE_LIMIT) {
|
|
return { ok: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
}
|
|
entry.count++;
|
|
return { ok: true };
|
|
}
|
|
|
|
app.use(express.json());
|
|
|
|
app.post("/login", (req: Request, res: Response) => {
|
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
const { ok, retryAfter } = checkRate(ip);
|
|
if (!ok) {
|
|
res.status(429).json({ error: `Слишком много попыток. Повтори через ${retryAfter} сек.` }); return;
|
|
}
|
|
const { user, pass } = req.body as { user?: string; pass?: string };
|
|
if (!ADMIN_PASS || user !== ADMIN_USER || pass !== ADMIN_PASS) {
|
|
res.status(401).json({ error: "Неверный логин или пароль" }); return;
|
|
}
|
|
loginAttempts.delete(ip);
|
|
res.json({ token: sign(ADMIN_USER) });
|
|
});
|
|
|
|
app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
|
const token = (req.headers["authorization"] as string).slice(7);
|
|
revoked.add(tokenSig(token));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Всё под /api закрыто JWT — свои роуты добавляй ниже этой строки.
|
|
app.use("/api", requireAuth);
|
|
|
|
// Проверка живости сессии: фронт зовёт его при загрузке, чтобы понять,
|
|
// валиден ли сохранённый токен.
|
|
app.get("/api/session", (req: Request, res: Response) => {
|
|
res.json({ user: (req as AuthRequest).claims?.sub ?? null });
|
|
});
|
|
|
|
// Статика SPA + fallback на index.html (роутинг на клиенте).
|
|
app.use(express.static(STATIC_DIR));
|
|
|
|
app.get("*", (_req: Request, res: Response) => {
|
|
res.sendFile(path.join(STATIC_DIR, "index.html"));
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
if (!ADMIN_PASS) console.warn("admin: ADMIN_PASS не задан — вход отключён");
|
|
console.log(`admin: listening on :${PORT}`);
|
|
}); |