// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача // собранного фронта. Всё, что во всех панелях было одинаковым, — здесь; // продуктовые маршруты остаются в самой панели. // // Авторизация есть всегда и приезжает готовой: задай // auth: { user, password | passwordHash, secret } — и панель сама выпускает и // проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации // панель не пишет. // // Панель, у которой собственных учёток нет вовсе (логин форвардится в основной // продукт, где живут настоящие учётки), createPanelServer не использует — // она собирает express сама из кусков ядра: createLoginRateLimit на маршрут // логина, свои прокси-маршруты, serveSpa последним. См. раздел про // панель-прокси в README. // // Порядок монтирования фиксирован и потому не может быть нарушен снаружи: // json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит // любой GET, поэтому маршрут после него уже не сработал бы. import express, { type Express, type Request, type Response } from 'express'; import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit'; import { createPasswordCheck, type PasswordOptions } from './password'; import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session'; import { createAuth, type Auth } from './auth'; import { forwardError } from './errors'; import { serveSpa } from './spa'; const LOGIN_PATH = '/login'; const LOGOUT_PATH = '/logout'; const SESSION_PATH = '/api/session'; export interface LocalAuthOptions extends PasswordOptions, Omit { /** Логин единственной учётки панели. */ user: string; /** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */ secret: string; /** * Клеймы, которые получит вошедший. По умолчанию — владелец: permissions * ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток — * здесь и появится разметка. */ claims?: Partial>; } export interface PanelServerOptions { /** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */ auth: LocalAuthOptions; /** Каталог со сборкой фронта. Без него статика не монтируется. */ staticDir?: string; rateLimit?: RateLimitOptions; /** Маршруты самой панели. Монтируются после логина и до статики. */ mountRoutes?: (app: Express, ctx: PanelServerContext) => void; } export interface PanelServerContext { app: Express; rateLimit: LoginRateLimit; sessions: SessionStore; auth: Auth; forwardError: typeof forwardError; } export function createPanelServer(options: PanelServerOptions): Express { const app = express(); const cfg = options.auth; app.use(express.json()); const rateLimit = createLoginRateLimit(options.rateLimit); // Обе проверки бросают при негодной конфигурации — панель не поднимется // с паролем по умолчанию или с известным секретом. const password = createPasswordCheck(cfg); const sessions = createSessionStore({ secret: cfg.secret, ttl: cfg.ttl, revocable: cfg.revocable, revocationStore: cfg.revocationStore, }); const auth = createAuth({ sessions }); app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => { const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string }; // Пароль проверяется всегда, даже когда логин уже не совпал: иначе // по времени ответа отличается «нет такого пользователя» от // «пароль неверный». const passOk = password.verify(pass ?? ''); const userOk = typeof user === 'string' && user === cfg.user; if (!userOk || !passOk) { res.status(401).json({ message: 'Неверный логин или пароль' }); return; } rateLimit.clear(req); res.json({ token: sessions.issue({ user: cfg.user, userId: cfg.claims?.userId ?? '1', role: cfg.claims?.role ?? 'admin', permissions: cfg.claims?.permissions ?? ['*'], }), }); }); app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => { sessions.revoke(auth.bearerToken(req)); res.status(204).end(); }); app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => { res.json(auth.getSession(req)); }); options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError }); if (options.staticDir) serveSpa(app, options.staticDir); return app; } export { createLoginRateLimit, clientIp } from './ratelimit'; export { createPasswordCheck, hashPassword, timingSafeEquals } from './password'; export { createSessionStore } from './session'; export { createAuth, bearerToken, OWNER_PERMISSION } from './auth'; export { forwardError } from './errors'; export { serveSpa } from './spa'; export type { RateLimitOptions, LoginRateLimit } from './ratelimit'; export type { PasswordOptions, PasswordCheck } from './password'; export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session'; export type { Auth, AuthOptions, Middleware } from './auth';