commit f4f645dcd3dbab294afd4a6812bac6d1d6dc9cc4 Author: Ivan Vasilev Date: Wed Aug 26 20:39:37 2026 +0300 ma7engine: ядро, шаблон панели, скаффолдер и доктор Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..1f7e047 --- /dev/null +++ b/auth.ts @@ -0,0 +1,86 @@ +// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу». +// +// Проверка прав на фронте (permission в metadata.json вкладки) — это только +// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь, +// и роут без requirePermission открыт всем залогиненным независимо от того, как +// размечена вкладка. +// +// Откуда берутся права — развилка без единственно верного ответа, поэтому она +// вынесена в конфиг (loadPermissions): +// из клеймов токена — дёшево, но снятое право действует к истечению TTL; +// из БД на запрос — дороже, зато отзыв мгновенный. +import type { NextFunction, Request, Response } from 'express'; +import type { SessionClaims, SessionStore } from './session'; + +export const OWNER_PERMISSION = '*'; + +// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с +// express-session и другими мидлварами, занимающими это имя. +const SESSION = Symbol.for('ma7engine.session'); + +export type Middleware = (req: Request, res: Response, next: NextFunction) => void; + +export interface AuthOptions { + sessions: SessionStore; + /** + * Права на конкретный запрос. По умолчанию — из клеймов токена. + * Верни свежие права из БД, если нужен мгновенный отзыв. + */ + loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise; + /** Тексты отказов — панели отдают ошибки как {message}. */ + messages?: { unauthorized?: string; forbidden?: string }; +} + +export interface Auth { + /** Пускает только с живым токеном; кладёт клеймы в запрос. */ + requireAuth: Middleware; + /** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */ + requirePermission: (permission: string) => Middleware; + /** Клеймы текущего запроса. Осмысленны только после requireAuth. */ + getSession: (req: Request) => SessionClaims; + /** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */ + bearerToken: (req: Request) => string; +} + +export function bearerToken(req: Request): string { + const header = req.headers.authorization ?? ''; + return header.startsWith('Bearer ') ? header.slice(7) : ''; +} + +export function createAuth(options: AuthOptions): Auth { + const { sessions, loadPermissions } = options; + const unauthorized = options.messages?.unauthorized ?? 'Не авторизован'; + const forbidden = options.messages?.forbidden ?? 'Недостаточно прав'; + + const getSession = (req: Request): SessionClaims => + (req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims; + + const requireAuth: Middleware = (req, res, next) => { + const claims = sessions.verify(bearerToken(req)); + if (!claims) { + res.status(401).json({ message: unauthorized }); + return; + } + (req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims; + next(); + }; + + const requirePermission = (permission: string): Middleware => (req, res, next) => { + const claims = getSession(req); + if (!claims) { + res.status(401).json({ message: unauthorized }); + return; + } + Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions) + .then((permissions) => { + if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) { + next(); + return; + } + res.status(403).json({ message: forbidden }); + }) + .catch(next); + }; + + return { requireAuth, requirePermission, getSession, bearerToken }; +} diff --git a/errors.ts b/errors.ts new file mode 100644 index 0000000..5f38915 --- /dev/null +++ b/errors.ts @@ -0,0 +1,37 @@ +// Проброс ошибок из вышестоящего API наружу. +// +// Панель, которая ходит за данными в основной API продукта, должна отдать +// клиенту его статус и текст, а не превратить всё в 500: «пользователь не +// найден» и «API лежит» — разные ситуации для оператора. +// +// Форма ошибки axios здесь проверяется по структуре, а не через +// axios.isAxiosError: ядру не нужна зависимость от axios ради одной проверки, +// а панель может ходить наверх и через fetch. +import type { Response } from 'express'; + +interface UpstreamError { + response?: { + status?: number; + data?: { message?: string; error?: string }; + }; +} + +export interface ForwardErrorOptions { + /** Текст, когда вышестоящий API недоступен вовсе. */ + unavailable?: string; + /** Текст, когда API ответил ошибкой без внятного сообщения. */ + fallback?: string; +} + +export function forwardError(res: Response, error: unknown, options: ForwardErrorOptions = {}) { + const unavailable = options.unavailable ?? 'Вышестоящий API недоступен'; + const fallback = options.fallback ?? 'Ошибка API'; + + const response = (error as UpstreamError)?.response; + if (response && typeof response.status === 'number') { + const data = response.data; + res.status(response.status).json({ message: data?.message ?? data?.error ?? fallback }); + return; + } + res.status(502).json({ message: unavailable }); +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d2b6a48 --- /dev/null +++ b/index.ts @@ -0,0 +1,128 @@ +// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача +// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь; +// продуктовые маршруты остаются в самой панели. +// +// Авторизация есть всегда и приезжает готовой: задай +// 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'; diff --git a/password.ts b/password.ts new file mode 100644 index 0000000..1c4bfc6 --- /dev/null +++ b/password.ts @@ -0,0 +1,82 @@ +// Проверка пароля единственной учётки панели. +// +// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному +// поведению: +// +// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения +// строк зависит от длины общего префикса. Здесь сравниваются SHA-256 +// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual +// бросает) и постоянное время. +// +// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и +// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро +// падает в закрытую и максимально рано: не на первом логине, а при старте +// процесса. Тихо работающая панель с паролем по умолчанию — худший из +// возможных исходов. +import crypto from 'node:crypto'; + +/** Сравнение за постоянное время. Строки произвольной длины. */ +export function timingSafeEquals(a: string, b: string): boolean { + const ha = crypto.createHash('sha256').update(a).digest(); + const hb = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +export interface PasswordOptions { + /** Пароль открытым текстом (обычно из ADMIN_PASS). */ + password?: string; + /** + * Либо хэш вида `scrypt::` (обычно из ADMIN_PASS_HASH) — + * тогда открытый пароль не хранится в окружении вовсе. + * Сгенерировать: `node -e "…"` — см. hashPassword ниже. + */ + passwordHash?: string; +} + +export interface PasswordCheck { + verify: (input: string) => boolean; + /** Как настроен пароль — для строки в логе при старте. */ + kind: 'plain' | 'scrypt'; +} + +const SCRYPT_PREFIX = 'scrypt:'; + +/** Готовый ADMIN_PASS_HASH для указанного пароля. */ +export function hashPassword(password: string): string { + const salt = crypto.randomBytes(16); + const key = crypto.scryptSync(password, salt, 32); + return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`; +} + +export function createPasswordCheck(options: PasswordOptions): PasswordCheck { + const { password, passwordHash } = options; + + if (passwordHash) { + if (!passwordHash.startsWith(SCRYPT_PREFIX)) { + throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()'); + } + const [, saltHex, keyHex] = passwordHash.split(':'); + if (!saltHex || !keyHex) { + throw new Error('panel-server: passwordHash повреждён, ожидается scrypt::'); + } + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(keyHex, 'hex'); + return { + kind: 'scrypt', + verify(input) { + const actual = crypto.scryptSync(input, salt, expected.length); + return crypto.timingSafeEqual(actual, expected); + }, + }; + } + + if (!password) { + throw new Error( + 'panel-server: пароль панели не задан.\n' + + ' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' + + ' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.', + ); + } + + return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) }; +} diff --git a/ratelimit.ts b/ratelimit.ts new file mode 100644 index 0000000..c44e5e9 --- /dev/null +++ b/ratelimit.ts @@ -0,0 +1,83 @@ +// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был +// скопирован дословно (те же 5 попыток за 15 минут), но в одной из них — +// с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For. +// За обратным прокси это адрес самого прокси, все клиенты схлопываются в один +// ключ, и пять неудачных попыток кем угодно блокируют вход всем. +// +// Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких +// инстансов за балансировщиком. Для защиты от перебора этого достаточно +// (атакующему всё равно приходится укладываться в лимит на каждом инстансе), +// но знать об этом надо — распределённый лимит требует общего хранилища. +import type { NextFunction, Request, Response } from 'express'; + +export interface RateLimitOptions { + /** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */ + limit?: number; + /** Длина окна в миллисекундах. По умолчанию 15 минут. */ + windowMs?: number; + /** + * Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде: + * в деве перед сервером прокси нет, и подставить чужой IP заголовком мог + * бы кто угодно, кто достучался напрямую. + */ + trustProxy?: boolean; + /** Текст ответа. Получает количество секунд до сброса. */ + message?: (retryAfter: number) => string; +} + +interface RateEntry { count: number; resetAt: number } + +type IpSource = Pick & { socket: { remoteAddress?: string } }; + +export function clientIp(req: IpSource, trustProxy: boolean): string { + if (trustProxy) { + const forwarded = req.headers['x-forwarded-for']; + const first = Array.isArray(forwarded) ? forwarded[0] : forwarded; + if (first) return first.split(',')[0].trim(); + } + return req.socket.remoteAddress ?? 'unknown'; +} + +export interface LoginRateLimit { + /** Мидлвар на маршрут логина. */ + middleware: (req: Request, res: Response, next: NextFunction) => void; + /** + * Снять счётчик после успешного входа — иначе три опечатки подряд перед + * удачным логином всё ещё считались бы попытками перебора. + */ + clear: (req: IpSource) => void; + /** IP запроса по тем же правилам, что использует лимитер. */ + ipOf: (req: IpSource) => string; +} + +export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit { + const limit = options.limit ?? 5; + const windowMs = options.windowMs ?? 15 * 60 * 1000; + const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production'; + const message = options.message + ?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`); + + const attempts = new Map(); + const ipOf = (req: IpSource) => clientIp(req, trustProxy); + + return { + ipOf, + clear: (req) => { attempts.delete(ipOf(req)); }, + middleware(req, res, next) { + const ip = ipOf(req); + const now = Date.now(); + const entry = attempts.get(ip); + if (!entry || entry.resetAt < now) { + attempts.set(ip, { count: 1, resetAt: now + windowMs }); + } else if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.setHeader('Retry-After', String(retryAfter)); + res.status(429).json({ error: message(retryAfter) }); + return; + } else { + entry.count++; + } + next(); + }, + }; +} diff --git a/session.ts b/session.ts new file mode 100644 index 0000000..ed2fd68 --- /dev/null +++ b/session.ts @@ -0,0 +1,93 @@ +// Сессия оператора панели: выпуск и проверка токена. +// +// Реализаций в панелях было две — подписанный JWT через jsonwebtoken и +// самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но +// краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке. +// jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели. +// +// Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому +// не может их отменить — логаут по умолчанию чисто клиентский. Опция +// `revocable` включает список отозванных подписей в памяти, и тогда логаут +// действительно убивает токен. Цена: список не переживает рестарт и не общий +// для нескольких инстансов за балансировщиком — после рестарта отозванный +// токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на +// нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай +// свой store вместо встроенного. +import jwt from 'jsonwebtoken'; + +export interface SessionClaims { + user: string; + userId: string; + role: string; + permissions: string[]; +} + +/** Хранилище отозванных токенов. По умолчанию — Set в памяти. */ +export interface RevocationStore { + revoke: (signature: string) => void; + isRevoked: (signature: string) => boolean; +} + +export interface SessionOptions { + /** Секрет подписи. Свой, отдельный от секретов основного продукта. */ + secret: string; + /** Время жизни токена. По умолчанию 12 часов. */ + ttl?: string | number; + /** Включить отзыв токенов на логауте. По умолчанию выключено. */ + revocable?: boolean; + /** Своё хранилище отозванных — например, общее на несколько инстансов. */ + revocationStore?: RevocationStore; +} + +export interface SessionStore { + issue: (claims: SessionClaims) => string; + /** Клеймы живого токена, либо null — протух, подделан или отозван. */ + verify: (token: string) => SessionClaims | null; + /** Отозвать токен (если отзыв включён). */ + revoke: (token: string) => void; + revocable: boolean; +} + +function memoryRevocationStore(): RevocationStore { + const revoked = new Set(); + return { + revoke: (sig) => { revoked.add(sig); }, + isRevoked: (sig) => revoked.has(sig), + }; +} + +// Подпись однозначно идентифицирует токен и короче его самого. +const signatureOf = (token: string) => token.split('.')[2] ?? token; + +export function createSessionStore(options: SessionOptions): SessionStore { + const { secret } = options; + if (!secret) { + throw new Error( + 'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' + + ' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.', + ); + } + const ttl = options.ttl ?? '12h'; + const revocable = options.revocable ?? false; + const store = options.revocationStore ?? memoryRevocationStore(); + + return { + revocable, + issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions), + verify(token) { + if (!token) return null; + if (revocable && store.isRevoked(signatureOf(token))) return null; + try { + // algorithms задан явно: без него подошёл бы токен, подписанный + // другим алгоритмом из заголовка самого токена. + const claims = jwt.verify(token, secret, { algorithms: ['HS256'] }); + return claims as SessionClaims & jwt.JwtPayload; + } catch { + return null; + } + }, + revoke(token) { + if (revocable && token) store.revoke(signatureOf(token)); + }, + }; +} diff --git a/spa.ts b/spa.ts new file mode 100644 index 0000000..4f8d6c2 --- /dev/null +++ b/spa.ts @@ -0,0 +1,32 @@ +// Раздача собранного фронта. Панель — один процесс и один образ: сервер сам +// отдаёт frontend/dist, отдельный nginx ей не нужен. +// +// Монтируется последним, после всех API-маршрутов: fallback отвечает на любой +// GET, поэтому маршрут, зарегистрированный после него, уже не сработает. +import express, { type Express, type Request, type Response } from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Пути под этими префиксами получают 404, а не index.html: иначе опечатка в +// адресе API возвращает HTML со статусом 200, и клиент падает на разборе JSON +// вместо внятной ошибки. +const API_PREFIXES = ['/api']; + +export function serveSpa(app: Express, dir: string) { + const root = path.resolve(dir); + + if (!fs.existsSync(path.join(root, 'index.html'))) { + console.warn(`panel-server: в ${root} нет index.html — фронт не собран (npm run build) ` + + 'или STATIC_DIR указывает не туда'); + } + + app.use(express.static(root)); + + app.get('*', (req: Request, res: Response) => { + if (API_PREFIXES.some(p => req.path.startsWith(p))) { + res.status(404).json({ message: 'Не найдено' }); + return; + } + res.sendFile(path.join(root, 'index.html')); + }); +}