import type { Request, Response, NextFunction } from 'express'; import { verifyAccessToken, verifyBotServiceToken, verifySuperAdminToken } from '../utils/jwt'; import { isSessionRevoked } from '../utils/sessionRevocation'; import { storage } from '../storage'; import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db'; import { eq } from 'drizzle-orm'; import { trackUserActivity } from '../utils/userActivity'; import { normalizeApiKeyScopes } from '../utils/api-key'; import type { ApiKeyScopes } from '@shared/schema'; 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; /** Контекст API-ключа (authenticateTokenOrApiKey), когда запрос авторизован ключом, а не JWT. * req.user при этом равен null. */ apiKey?: RequestApiKeyContext; } // Контекст авторизации по API-ключу организации export interface RequestApiKeyContext { id: number; organizationId: number; botId: number | null; createdBy: number; label: string; scopes: ApiKeyScopes; } 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); // Токены ботов (старые с appRole='bot' или любые с type='bot*') не принимаются // на пользовательских ресурсах — это закрывает коллизию bot.id ↔ user.id. const payloadType = (decoded as { type?: string }).type; if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } // Отзыв устройства: сессия из claim sid отозвана → токен недействителен // (проверка с кэшем ~30 сек, см. utils/sessionRevocation) if (decoded.sid && await isSessionRevoked(decoded.sid)) { return res.status(401).json({ error: 'Сессия отозвана' }); } // sid сессии — для пометки «текущее устройство» в списке сессий if (decoded.sid) { (req as any).sessionId = decoded.sid; } // 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: 'Недействительный токен' }); } }; // ── API-ключи: белый список endpoint'ов, доступных по ключу (REST) ─────────── // Проверяется по originalUrl только когда запрос авторизован ключом (не JWT). const API_KEY_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [ { method: 'POST', pattern: /^\/api\/upload(\?|$)/ }, { method: 'POST', pattern: /^\/api\/tasks\/\d+\/messages(\?|$)/ }, { method: 'PATCH', pattern: /^\/api\/tasks\/\d+\/field-values\/\d+(\?|$)/ }, { method: 'POST', pattern: /^\/api\/tasks\/\d+\/field-values(\?|$)/ }, { method: 'POST', pattern: /^\/api\/forms\/\d+\/tasks(\?|$)/ }, ]; // Разрешает запрос по JWT пользователя (поведение authenticateToken не меняется) // либо по API-ключу организации (X-Api-Key или Authorization: Bearer ). // При авторизации ключом: req.user = null, req.apiKey заполнен, // req.organizationId = key.organizationId, tenant-контекст открывается так же, // как в authenticateToken. Legacy/неактивные ключи отклоняются. export const authenticateTokenOrApiKey = async ( req: AuthenticatedRequest, res: Response, next: NextFunction ) => { 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 apiKeyHeader = (req.headers['x-api-key'] as string | undefined)?.trim(); // 1. JWT пользователя (cookie или Bearer) — как в authenticateToken const userJwt = cookieToken || (!apiKeyHeader ? headerToken : null); if (userJwt) { try { const decoded = verifyAccessToken(userJwt); // Токены ботов не принимаются (та же проверка, что в authenticateToken) const payloadType = (decoded as { type?: string }).type; if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } 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)); return; } catch { // JWT невалиден — если Bearer-токен задан, пробуем его как API-ключ if (!headerToken) { return res.status(403).json({ error: 'Недействительный токен' }); } } } // 2. API-ключ организации const rawKey = apiKeyHeader || headerToken; if (!rawKey) { return res.status(401).json({ error: 'Токен доступа отсутствует' }); } try { // getApiKeyByHash сам фильтрует isActive и isLegacy=false const key = await storage.getApiKeyByHash(rawKey); if (!key || !key.isActive) { return res.status(401).json({ error: 'Недействительный API-ключ' }); } // Endpoint должен быть в белом списке для API-ключей const allowed = API_KEY_ALLOWED_ROUTES.some( (r) => r.method === req.method && r.pattern.test(req.originalUrl) ); if (!allowed) { return res.status(403).json({ error: 'API-ключ не поддерживается на этом ресурсе' }); } storage.touchApiKey(key.id).catch(() => {}); req.apiKey = { id: key.id, organizationId: key.organizationId, botId: key.botId ?? null, createdBy: key.createdBy, label: key.label, scopes: normalizeApiKeyScopes(key.scopes), }; req.user = null; req.organizationId = key.organizationId; if (_tenantCtx.getStore()) return next(); openTenantCtx(key.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(401).json({ error: 'Недействительный API-ключ' }); } }; /** * 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); // Токены ботов не принимаются и на файловых ресурсах (та же коллизия id) const payloadType = (decoded as { type?: string }).type; if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } 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 | null = null; let _permissionCacheTs = 0; const PERMISSION_CACHE_TTL = 60_000; // 1 minute async function loadPermissionCache(): Promise> { 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(); 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 { 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: 'Недостаточно прав' }); } // Модульный флаг доступа к Финансам (users/roles.finance_access) — // дополнительный ограничитель поверх права finance.*: только отбирает, // не добавляет. DEFAULT true — при выкатке поведение не меняется. if (codes.some((c) => c.startsWith('finance.'))) { const { hasModuleAccess } = await import('../utils/module-access'); const allowed = await hasModuleAccess(req.user, 'finance'); if (!allowed) { 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 => { 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)); };