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