Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
129 lines
6.4 KiB
TypeScript
129 lines
6.4 KiB
TypeScript
// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача
|
|
// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь;
|
|
// продуктовые маршруты остаются в самой панели.
|
|
//
|
|
// Авторизация есть всегда и приезжает готовой: задай
|
|
// 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';
|