fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
350
server/middleware/auth.middleware.ts
Normal file
350
server/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
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));
|
||||
};
|
||||
Reference in New Issue
Block a user