- 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
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
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;
|
||
}
|