Files
iistwin/server/middleware/auth.middleware.ts

524 lines
21 KiB
TypeScript
Raw Permalink 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.

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 <key>).
// При авторизации ключом: 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<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: 'Недостаточно прав' });
}
// Модульный флаг доступа к Финансам (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<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));
};