Безопасность: 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:
2026-07-22 14:32:11 +03:00
parent 72de9a25fb
commit 1fae441a09
4 changed files with 99 additions and 18 deletions

View File

@@ -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'],