Files
ma7engine/core/server/auth.ts
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

87 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу».
//
// Проверка прав на фронте (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 };
}