Files
iistwin/server/middleware/auth.middleware.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

351 lines
12 KiB
TypeScript

import type { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, verifyBotServiceToken, verifySuperAdminToken } from '../utils/jwt';
import { storage } from '../storage';
import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db';
import { eq } from 'drizzle-orm';
import { trackUserActivity } from '../utils/userActivity';
export interface AuthenticatedRequest extends Request {
user?: any;
organizationId?: number;
/** True when the request was authenticated with a bot-service JWT (type: 'bot_service').
* Routes should skip bot-trigger logic when this flag is set to prevent message loops. */
isBotToken?: boolean;
}
export interface SuperAdminRequest extends Request {
superAdmin?: { id: number; email: string; name?: string };
}
const BILLING_EXEMPT_PREFIXES = [
'/api/auth/',
'/api/superadmin/',
'/api/billing/',
'/api/health',
];
// Validates Bearer JWT and populates req.user. After successful auth, opens a
// per-request pg client with SET LOCAL app.current_org_id so all subsequent
// db.* calls in the handler automatically use the tenant-scoped connection.
// This covers every route that uses authenticateToken — no per-route wiring needed.
export const authenticateToken = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
// Already authenticated as bot-service by tryBotServiceToken — skip user lookup.
if (req.isBotToken) {
return next();
}
const authHeader = req.headers['authorization'];
const headerToken = authHeader && authHeader.split(' ')[1];
const cookieToken = (req as any).cookies?.access_token;
const token = cookieToken || headerToken;
if (!token) {
return res.status(401).json({ error: 'Токен доступа отсутствует' });
}
try {
const decoded = verifyAccessToken(token);
// Use JWT organizationId to set tenant context for the users table lookup,
// preventing auth failure when FORCE RLS is active on the users table.
const user = await withTenant(decoded.organizationId, () =>
storage.getUserWithOrganization(decoded.userId)
);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
}
if (user.organization && !user.organization.isActive) {
return res.status(403).json({ error: 'Доступ организации заблокирован' });
}
req.user = user;
req.organizationId = user.organizationId;
trackUserActivity(user.id);
const isBillingExempt = BILLING_EXEMPT_PREFIXES.some(p => req.path.startsWith(p));
if (!isBillingExempt && user.organization?.billingBlocked) {
return res.status(402).json({
error: 'Доступ приостановлен',
blocked: true,
reason: 'insufficient_balance',
message: 'Баланс организации исчерпан. Обратитесь к администратору для пополнения.',
});
}
// Open a per-request tenant context if one is not already active.
// SSE connections (text/event-stream) skip the long-lived transaction —
// their individual DB calls use withTenant point-operations instead.
if (_tenantCtx.getStore()) {
return next();
}
const isSSE = req.headers.accept?.includes('text/event-stream');
if (isSSE) {
return next();
}
openTenantCtx(user.organizationId)
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err) => next(err as Error));
} catch {
return res.status(403).json({ error: 'Недействительный токен' });
}
};
/**
* Optional pre-middleware: detects a bot-service JWT (type: 'bot_service') and sets
* req.isBotToken = true when found. authenticateToken then skips its user-lookup step.
*
* Apply only to routes that must accept both user tokens and bot service tokens, e.g.:
* router.post('/…', tryBotServiceToken, authenticateToken, handler)
*
* Routes that only ever handle human users must NOT use this middleware, preserving
* the invariant that req.user is always set after authenticateToken.
*/
export const tryBotServiceToken = (
req: AuthenticatedRequest,
_res: Response,
next: NextFunction
): void => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
try {
const botDecoded = verifyBotServiceToken(token);
req.isBotToken = true;
req.organizationId = botDecoded.organizationId;
} catch {
// Not a bot-service token — authenticateToken will handle it normally.
}
}
next();
};
// Like authenticateToken but also accepts ?token= for file-download routes.
export const authenticateFileToken = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers['authorization'];
const headerToken = authHeader && authHeader.split(' ')[1];
const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
const cookieToken = (req as any).cookies?.access_token;
const token = cookieToken || headerToken || queryToken;
if (!token) {
return res.status(401).json({ error: 'Токен доступа отсутствует' });
}
try {
const decoded = verifyAccessToken(token);
const user = await withTenant(decoded.organizationId, () =>
storage.getUserWithOrganization(decoded.userId)
);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
}
if (user.organization && !user.organization.isActive) {
return res.status(403).json({ error: 'Доступ организации заблокирован' });
}
req.user = user;
req.organizationId = user.organizationId;
trackUserActivity(user.id);
if (_tenantCtx.getStore()) return next();
openTenantCtx(user.organizationId)
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err) => next(err as Error));
} catch {
return res.status(403).json({ error: 'Недействительный токен' });
}
};
/**
* @deprecated Use requirePermission(...) instead.
* Kept for transitional compatibility during the role refactor.
*/
export const requireAdmin = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
const role = req.user?.appRole;
if (!req.user || role !== 'admin') {
return res.status(403).json({ error: 'Требуются права администратора' });
}
next();
};
// Permission cache (appRole slug -> string[] of permission codes)
let _permissionCache: Map<string, string[]> | null = null;
let _permissionCacheTs = 0;
const PERMISSION_CACHE_TTL = 60_000; // 1 minute
async function loadPermissionCache(): Promise<Map<string, string[]>> {
const now = Date.now();
if (_permissionCache && (now - _permissionCacheTs) < PERMISSION_CACHE_TTL) {
return _permissionCache;
}
const { db } = await import('../db');
const { appRoles: arTable, permissions: pTable, appRolePermissions: arpTable } = await import('@shared/schema');
const rows = await db.select({
appRoleSlug: arTable.slug,
permissionCode: pTable.code,
}).from(arpTable)
.innerJoin(arTable, eq(arpTable.appRoleId, arTable.id))
.innerJoin(pTable, eq(arpTable.permissionId, pTable.id));
const map = new Map<string, string[]>();
for (const r of rows) {
if (!map.has(r.appRoleSlug)) map.set(r.appRoleSlug, []);
map.get(r.appRoleSlug)!.push(r.permissionCode);
}
_permissionCache = map;
_permissionCacheTs = now;
return map;
}
export async function hasAppRolePermission(appRole: string, ...codes: string[]): Promise<boolean> {
const cache = await loadPermissionCache();
const perms = cache.get(appRole) || [];
return codes.some(c => perms.includes(c));
}
export const requirePermission = (...codes: string[]) => {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(403).json({ error: 'Нет доступа' });
}
const role = req.user.appRole;
if (!role) {
return res.status(403).json({ error: 'Нет доступа' });
}
const has = await hasAppRolePermission(role, ...codes);
if (!has) {
return res.status(403).json({ error: 'Недостаточно прав' });
}
next();
};
};
/**
* Requires either a global app-role permission OR admin-level access to the
* form identified by `formIdParam` (author or formAccessRules.accessLevel === 'admin').
* Use this for mutating form endpoints so that form admins can manage their own
* forms without needing the global `forms.manage` permission.
*/
export const requireFormAdminOrPermission = (permissionCode: string, formIdParam = 'id') => {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(403).json({ error: 'Нет доступа' });
}
const role = req.user.appRole;
if (!role) {
return res.status(403).json({ error: 'Нет доступа' });
}
const hasGlobal = await hasAppRolePermission(role, permissionCode);
if (hasGlobal) {
return next();
}
const rawFormId = req.params[formIdParam];
const formId = typeof rawFormId === 'string' ? parseInt(rawFormId, 10) : NaN;
if (!isNaN(formId) && req.organizationId) {
const isFormAdmin = await storage.canUserAccessForm(req.user.id, formId, req.organizationId, 'admin');
if (isFormAdmin) {
return next();
}
}
return res.status(403).json({ error: 'Недостаточно прав' });
};
};
export const requireActiveUser = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
if (!req.user || !req.user.isActive) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
next();
};
// Validates superadmin JWT and opens a per-request SA-scoped connection
// (SET LOCAL app.is_superadmin='true') so all storage queries in any
// superadmin route automatically bypass tenant RLS.
export const requireSuperAdmin = async (
req: SuperAdminRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const authHeader = req.headers['authorization'];
const headerToken = authHeader && authHeader.split(' ')[1];
const cookieToken = (req as any).cookies?.superadmin_token;
const token = headerToken || cookieToken;
if (!token) {
res.status(401).json({ error: 'Токен суперадмина отсутствует' });
return;
}
try {
const payload = verifySuperAdminToken(token);
const admin = await storage.getSuperAdminById(payload.superAdminId);
if (!admin) {
res.status(403).json({ error: 'Суперадмин не найден или был удалён' });
return;
}
req.superAdmin = { id: admin.id, email: admin.email };
} catch {
res.status(403).json({ error: 'Недействительный токен суперадмина' });
return;
}
if (req.headers.accept?.includes('text/event-stream')) {
next();
return;
}
openSuperAdminCtx()
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing superadmin connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err) => next(err as Error));
};