ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
@@ -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<string[]>;
|
||||||
|
/** Тексты отказов — панели отдают ошибки как {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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<SessionOptions, 'secret'> {
|
||||||
|
/** Логин единственной учётки панели. */
|
||||||
|
user: string;
|
||||||
|
/** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */
|
||||||
|
secret: string;
|
||||||
|
/**
|
||||||
|
* Клеймы, которые получит вошедший. По умолчанию — владелец: permissions
|
||||||
|
* ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток —
|
||||||
|
* здесь и появится разметка.
|
||||||
|
*/
|
||||||
|
claims?: Partial<Omit<SessionClaims, 'user'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
+82
@@ -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:<salt-hex>:<key-hex>` (обычно из 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:<salt-hex>:<key-hex>');
|
||||||
|
}
|
||||||
|
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) };
|
||||||
|
}
|
||||||
@@ -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<Request, 'headers'> & { 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<string, RateEntry>();
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+93
@@ -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<string>();
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user