Files
iistwin/server/middleware/auth.middleware.ts
Ильяс Султанов 1fae441a09 Безопасность: bot-токены выделены в отдельный тип bot_login (этап 0)
- POST /api/bot/auth/login выдаёт bot_login/bot_login_refresh (audience workflow-bots, TTL 30d/90d)
- authenticateBot принимает bot_login и bot_service
- authenticateToken/authenticateFileToken отклоняют bot-токены (401) — закрыта коллизия bot.id с user.id
2026-07-22 14:32:11 +03:00

362 lines
13 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.

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);
// Токены ботов (старые с 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: 'Токены ботов не принимаются на этом ресурсе' });
}
// 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);
// Токены ботов не принимаются и на файловых ресурсах (та же коллизия 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: 'Недостаточно прав' });
}
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));
};