Files
iistwin/server/utils/jwt.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

246 lines
8.0 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 jwt from 'jsonwebtoken';
import crypto from 'crypto';
export function parseExpiryToSeconds(expiry: string): number {
const match = expiry.match(/^(\d+)([smhd])$/);
if (!match) return 15 * 60;
const [, num, unit] = match;
const n = parseInt(num, 10);
switch (unit) {
case 's': return n;
case 'm': return n * 60;
case 'h': return n * 3600;
case 'd': return n * 86400;
default: return 15 * 60;
}
}
function requireSecret(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`FATAL: JWT secrets not configured — missing ${name}.\n` +
'Set JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_SUPERADMIN_SECRET in environment variables.'
);
}
return value;
}
const ACCESS_TOKEN_SECRET = requireSecret('JWT_ACCESS_SECRET');
const REFRESH_TOKEN_SECRET = requireSecret('JWT_REFRESH_SECRET');
const SUPERADMIN_TOKEN_SECRET = requireSecret('JWT_SUPERADMIN_SECRET');
const BOT_TOKEN_SECRET = process.env.JWT_BOT_SECRET || ACCESS_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '30d';
const REFRESH_TOKEN_EXPIRY_REMEMBER = process.env.JWT_REFRESH_EXPIRES_REMEMBER || '90d';
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;
organizationId: number;
appRole: string;
role?: string; // legacy fallback for old tokens
}
export interface BotServiceTokenPayload {
botId: number;
organizationId: number;
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;
isSuperAdmin: true;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
familyId?: string;
}
export function generateTokens(payload: TokenPayload, remember: boolean = false, familyId?: string): TokenPair {
// Ensure we don't include legacy role in new tokens
const { role, ...cleanPayload } = payload as any;
const accessToken = jwt.sign(cleanPayload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
issuer: 'workflow-system',
audience: 'workflow-users'
});
const refreshToken = jwt.sign(cleanPayload, REFRESH_TOKEN_SECRET, {
expiresIn: (remember ? REFRESH_TOKEN_EXPIRY_REMEMBER : REFRESH_TOKEN_EXPIRY_SESSION) as jwt.SignOptions['expiresIn'],
issuer: 'workflow-system',
audience: 'workflow-users'
});
return {
accessToken,
refreshToken,
expiresIn: parseExpiryToSeconds(ACCESS_TOKEN_EXPIRY),
familyId
};
}
export function verifyAccessToken(token: string): TokenPayload {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],
issuer: 'workflow-system',
audience: 'workflow-users',
}) as TokenPayload;
// Fallback for old tokens that used 'role' instead of 'appRole'
if (!decoded.appRole && decoded.role) {
decoded.appRole = decoded.role;
}
return decoded;
}
export function verifyRefreshToken(token: string): TokenPayload {
const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET, {
algorithms: ['HS256'],
issuer: 'workflow-system',
audience: 'workflow-users',
}) as TokenPayload;
// Fallback for old tokens that used 'role' instead of 'appRole'
if (!decoded.appRole && decoded.role) {
decoded.appRole = decoded.role;
}
return decoded;
}
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export function generateBotServiceToken(botId: number, organizationId: number): string {
const payload: BotServiceTokenPayload = {
botId,
organizationId,
type: 'bot_service'
};
return jwt.sign(payload, BOT_TOKEN_SECRET, {
expiresIn: BOT_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
issuer: 'workflow-system',
audience: 'workflow-bots'
});
}
export function verifyBotServiceToken(token: string): BotServiceTokenPayload {
const payload = jwt.verify(token, BOT_TOKEN_SECRET, {
issuer: 'workflow-system',
audience: 'workflow-bots'
}) as BotServiceTokenPayload;
if (payload.type !== 'bot_service') {
throw new Error('Invalid token type');
}
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'],
issuer: 'workflow-system',
audience: 'workflow-superadmin'
});
}
export function verifySuperAdminToken(token: string): SuperAdminTokenPayload {
const payload = jwt.verify(token, SUPERADMIN_TOKEN_SECRET, {
issuer: 'workflow-system',
audience: 'workflow-superadmin'
}) as SuperAdminTokenPayload;
if (!payload.isSuperAdmin) {
throw new Error('Invalid super admin token');
}
return payload;
}