From 1fae441a09897b5cc16de268630ab6831163b03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Jul 2026 14:32:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C:=20bot-=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B8=D0=BF=20bot=5Flogin=20(=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/middleware/auth.middleware.ts | 11 ++++ server/routes/bot-api.routes.ts | 20 +++----- server/routes/bots-crud.routes.ts | 10 ++-- server/utils/jwt.ts | 76 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 1a023f7..0c33732 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -49,6 +49,12 @@ export const authenticateToken = async ( 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, () => @@ -152,6 +158,11 @@ export const authenticateFileToken = async ( 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) ); diff --git a/server/routes/bot-api.routes.ts b/server/routes/bot-api.routes.ts index 7ee5b8c..f109d5e 100644 --- a/server/routes/bot-api.routes.ts +++ b/server/routes/bot-api.routes.ts @@ -6,7 +6,7 @@ import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { botMessageSchema, conversations, conversationMessages, conversationMembers, users, bots } from "@shared/schema"; -import { verifyAccessToken } from "../utils/jwt"; +import { verifyBotLoginToken, verifyBotServiceToken } from "../utils/jwt"; import { sendWebhook } from "../utils/webhook"; import { eventBus } from "./shared"; import { pushTaskUpdated } from "../utils/pushTaskUpdated"; @@ -23,7 +23,7 @@ export function registerBotApiRoutes(app: import("express").Express): void { // Middleware для аутентификации ботов // Поддерживает два типа токенов: - // 1. Обычный токен бота (role: 'bot') - получается через /api/bot/auth/login + // 1. Токен логина бота (type: 'bot_login') - получается через /api/bot/auth/login // 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record }, res: Response, next: NextFunction) => { try { @@ -37,23 +37,19 @@ export function registerBotApiRoutes(app: import("express").Express): void { const token = authHeader.substring(7); - // Попробуем сначала как обычный токен бота + // Принимаются два типа токенов: + // 1. bot_login (POST /api/bot/auth/login) — через verifyBotLoginToken + // 2. bot_service (webhook @mention) — через verifyBotServiceToken let botId: number; let organizationId: number; try { - const decoded = verifyAccessToken(token); - if (decoded.appRole === 'bot') { - // Обычный токен бота (userId содержит botId) - botId = decoded.userId; - organizationId = decoded.organizationId; - } else { - throw new Error('Not a bot token'); - } + const decoded = verifyBotLoginToken(token); + botId = decoded.botId; + organizationId = decoded.organizationId; } catch { // Попробуем как сервисный токен try { - const { verifyBotServiceToken } = await import('../utils/jwt'); const serviceDecoded = verifyBotServiceToken(token); botId = serviceDecoded.botId; organizationId = serviceDecoded.organizationId; diff --git a/server/routes/bots-crud.routes.ts b/server/routes/bots-crud.routes.ts index f22dd45..9333114 100644 --- a/server/routes/bots-crud.routes.ts +++ b/server/routes/bots-crud.routes.ts @@ -9,7 +9,7 @@ import { createBotSchema, updateBotSchema, botLoginSchema, mcpServerSchema, } from "@shared/schema"; -import { generateTokens } from "../utils/jwt"; +import { generateBotLoginTokens } from "../utils/jwt"; import { verifyPassword } from "../utils/password"; import { authLimiter } from "./shared"; import { encrypt, decrypt } from "../crypto"; @@ -75,11 +75,9 @@ export function registerBotCrudRoutes(app: import("express").Express): void { return res.status(401).json({ success: false, error: 'Неверный логин или пароль' }); } - const tokens = generateTokens({ - userId: bot.id, - organizationId: organization.id, - appRole: 'bot' - }); + // Отдельный тип токена bot_login: НЕ пользовательский JWT — бот не может + // войти как пользователь с совпадающим числовым id (закрыта коллизия id). + const tokens = generateBotLoginTokens(bot.id, organization.id); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); await storage.createBotSession({ diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts index bf44c83..b605fa6 100644 --- a/server/utils/jwt.ts +++ b/server/utils/jwt.ts @@ -36,6 +36,8 @@ const REFRESH_TOKEN_EXPIRY_REMEMBER = process.env.JWT_REFRESH_EXPIRES_REMEMBER | const REFRESH_TOKEN_EXPIRY_SESSION = process.env.JWT_REFRESH_EXPIRES_SESSION || '30d'; const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h'; const BOT_TOKEN_EXPIRY = process.env.JWT_BOT_EXPIRES || '10m'; +const BOT_LOGIN_TOKEN_EXPIRY = process.env.JWT_BOT_LOGIN_EXPIRES || '30d'; +const BOT_LOGIN_REFRESH_EXPIRY = process.env.JWT_BOT_LOGIN_REFRESH_EXPIRES || '90d'; export interface TokenPayload { userId: number; @@ -50,6 +52,23 @@ export interface BotServiceTokenPayload { type: 'bot_service'; } +// Payload access-токена бота, выданного через POST /api/bot/auth/login. +// Отдельный тип (не пользовательский TokenPayload): такой токен НЕ проходит +// verifyAccessToken (другой audience) и отклоняется authenticateToken. +export interface BotLoginTokenPayload { + botId: number; + organizationId: number; + type: 'bot_login'; +} + +// Payload refresh-токена бота (хранится в bot_sessions, для будущего refresh-эндпоинта). +// Отдельный type, чтобы refresh нельзя было использовать как access. +export interface BotLoginRefreshTokenPayload { + botId: number; + organizationId: number; + type: 'bot_login_refresh'; +} + export interface SuperAdminTokenPayload { superAdminId: number; email: string; @@ -147,6 +166,63 @@ export function verifyBotServiceToken(token: string): BotServiceTokenPayload { return payload; } +// ── Bot login tokens (POST /api/bot/auth/login) ────────────────────────────── +// Access/refresh пара для ботов. Секрет и audience общие с bot_service, +// но type отдельный — verifyBotLoginToken принимает только 'bot_login'. + +export function generateBotLoginToken(botId: number, organizationId: number): string { + const payload: BotLoginTokenPayload = { botId, organizationId, type: 'bot_login' }; + return jwt.sign(payload, BOT_TOKEN_SECRET, { + expiresIn: BOT_LOGIN_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'], + issuer: 'workflow-system', + audience: 'workflow-bots' + }); +} + +export function generateBotLoginRefreshToken(botId: number, organizationId: number): string { + const payload: BotLoginRefreshTokenPayload = { botId, organizationId, type: 'bot_login_refresh' }; + return jwt.sign(payload, BOT_TOKEN_SECRET, { + expiresIn: BOT_LOGIN_REFRESH_EXPIRY as jwt.SignOptions['expiresIn'], + issuer: 'workflow-system', + audience: 'workflow-bots' + }); +} + +// Пара access+refresh для bot login — формат ответа совпадает с TokenPair. +export function generateBotLoginTokens(botId: number, organizationId: number): TokenPair { + return { + accessToken: generateBotLoginToken(botId, organizationId), + refreshToken: generateBotLoginRefreshToken(botId, organizationId), + expiresIn: parseExpiryToSeconds(BOT_LOGIN_TOKEN_EXPIRY), + }; +} + +export function verifyBotLoginToken(token: string): BotLoginTokenPayload { + const payload = jwt.verify(token, BOT_TOKEN_SECRET, { + issuer: 'workflow-system', + audience: 'workflow-bots' + }) as BotLoginTokenPayload; + + if (payload.type !== 'bot_login') { + throw new Error('Invalid token type'); + } + + return payload; +} + +export function verifyBotLoginRefreshToken(token: string): BotLoginRefreshTokenPayload { + const payload = jwt.verify(token, BOT_TOKEN_SECRET, { + issuer: 'workflow-system', + audience: 'workflow-bots' + }) as BotLoginRefreshTokenPayload; + + if (payload.type !== 'bot_login_refresh') { + throw new Error('Invalid token type'); + } + + return payload; +} + export function generateSuperAdminToken(payload: SuperAdminTokenPayload): string { return jwt.sign(payload, SUPERADMIN_TOKEN_SECRET, { expiresIn: SUPERADMIN_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],