Files
iistwin/server/utils/jwt.ts
Ильяс Султанов 06bfd2aa75
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex)
- migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся
- storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии
- auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена
- auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме
- tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset)
- .env.example, swagger, IMPLEMENTATION_LOG.md обновлены

Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
2026-09-08 11:35:32 +03:00

253 lines
8.5 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;
// Шаг 0.6: дефолт access-токена — 15 минут (было 30d). Env-переопределение сохранено.
export const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '15m';
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');
}
// sha256-хэш одноразовых/долгоживущих токенов для хранения в БД (шаг 0.6).
// Plain-токены refresh/password-reset в БД не хранятся — только этот хэш.
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('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;
}