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,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';
|
||||
Reference in New Issue
Block a user