Безопасность: 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
This commit is contained in:
@@ -49,6 +49,12 @@ export const authenticateToken = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = verifyAccessToken(token);
|
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,
|
// Use JWT organizationId to set tenant context for the users table lookup,
|
||||||
// preventing auth failure when FORCE RLS is active on the users table.
|
// preventing auth failure when FORCE RLS is active on the users table.
|
||||||
const user = await withTenant(decoded.organizationId, () =>
|
const user = await withTenant(decoded.organizationId, () =>
|
||||||
@@ -152,6 +158,11 @@ export const authenticateFileToken = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = verifyAccessToken(token);
|
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, () =>
|
const user = await withTenant(decoded.organizationId, () =>
|
||||||
storage.getUserWithOrganization(decoded.userId)
|
storage.getUserWithOrganization(decoded.userId)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth
|
|||||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||||
import { validateRequest } from "../middleware/validation.middleware";
|
import { validateRequest } from "../middleware/validation.middleware";
|
||||||
import { botMessageSchema, conversations, conversationMessages, conversationMembers, users, bots } from "@shared/schema";
|
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 { sendWebhook } from "../utils/webhook";
|
||||||
import { eventBus } from "./shared";
|
import { eventBus } from "./shared";
|
||||||
import { pushTaskUpdated } from "../utils/pushTaskUpdated";
|
import { pushTaskUpdated } from "../utils/pushTaskUpdated";
|
||||||
@@ -23,7 +23,7 @@ export function registerBotApiRoutes(app: import("express").Express): void {
|
|||||||
|
|
||||||
// Middleware для аутентификации ботов
|
// Middleware для аутентификации ботов
|
||||||
// Поддерживает два типа токенов:
|
// Поддерживает два типа токенов:
|
||||||
// 1. Обычный токен бота (role: 'bot') - получается через /api/bot/auth/login
|
// 1. Токен логина бота (type: 'bot_login') - получается через /api/bot/auth/login
|
||||||
// 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention
|
// 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention
|
||||||
const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res: Response, next: NextFunction) => {
|
const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -37,23 +37,19 @@ export function registerBotApiRoutes(app: import("express").Express): void {
|
|||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
// Попробуем сначала как обычный токен бота
|
// Принимаются два типа токенов:
|
||||||
|
// 1. bot_login (POST /api/bot/auth/login) — через verifyBotLoginToken
|
||||||
|
// 2. bot_service (webhook @mention) — через verifyBotServiceToken
|
||||||
let botId: number;
|
let botId: number;
|
||||||
let organizationId: number;
|
let organizationId: number;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = verifyAccessToken(token);
|
const decoded = verifyBotLoginToken(token);
|
||||||
if (decoded.appRole === 'bot') {
|
botId = decoded.botId;
|
||||||
// Обычный токен бота (userId содержит botId)
|
organizationId = decoded.organizationId;
|
||||||
botId = decoded.userId;
|
|
||||||
organizationId = decoded.organizationId;
|
|
||||||
} else {
|
|
||||||
throw new Error('Not a bot token');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Попробуем как сервисный токен
|
// Попробуем как сервисный токен
|
||||||
try {
|
try {
|
||||||
const { verifyBotServiceToken } = await import('../utils/jwt');
|
|
||||||
const serviceDecoded = verifyBotServiceToken(token);
|
const serviceDecoded = verifyBotServiceToken(token);
|
||||||
botId = serviceDecoded.botId;
|
botId = serviceDecoded.botId;
|
||||||
organizationId = serviceDecoded.organizationId;
|
organizationId = serviceDecoded.organizationId;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
createBotSchema, updateBotSchema,
|
createBotSchema, updateBotSchema,
|
||||||
botLoginSchema, mcpServerSchema,
|
botLoginSchema, mcpServerSchema,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { generateTokens } from "../utils/jwt";
|
import { generateBotLoginTokens } from "../utils/jwt";
|
||||||
import { verifyPassword } from "../utils/password";
|
import { verifyPassword } from "../utils/password";
|
||||||
import { authLimiter } from "./shared";
|
import { authLimiter } from "./shared";
|
||||||
import { encrypt, decrypt } from "../crypto";
|
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: 'Неверный логин или пароль' });
|
return res.status(401).json({ success: false, error: 'Неверный логин или пароль' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = generateTokens({
|
// Отдельный тип токена bot_login: НЕ пользовательский JWT — бот не может
|
||||||
userId: bot.id,
|
// войти как пользователь с совпадающим числовым id (закрыта коллизия id).
|
||||||
organizationId: organization.id,
|
const tokens = generateBotLoginTokens(bot.id, organization.id);
|
||||||
appRole: 'bot'
|
|
||||||
});
|
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
await storage.createBotSession({
|
await storage.createBotSession({
|
||||||
|
|||||||
@@ -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 REFRESH_TOKEN_EXPIRY_SESSION = process.env.JWT_REFRESH_EXPIRES_SESSION || '30d';
|
||||||
const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h';
|
const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h';
|
||||||
const BOT_TOKEN_EXPIRY = process.env.JWT_BOT_EXPIRES || '10m';
|
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 {
|
export interface TokenPayload {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -50,6 +52,23 @@ export interface BotServiceTokenPayload {
|
|||||||
type: 'bot_service';
|
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 {
|
export interface SuperAdminTokenPayload {
|
||||||
superAdminId: number;
|
superAdminId: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -147,6 +166,63 @@ export function verifyBotServiceToken(token: string): BotServiceTokenPayload {
|
|||||||
return payload;
|
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 {
|
export function generateSuperAdminToken(payload: SuperAdminTokenPayload): string {
|
||||||
return jwt.sign(payload, SUPERADMIN_TOKEN_SECRET, {
|
return jwt.sign(payload, SUPERADMIN_TOKEN_SECRET, {
|
||||||
expiresIn: SUPERADMIN_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
|
expiresIn: SUPERADMIN_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
|
||||||
|
|||||||
Reference in New Issue
Block a user