// Сессия оператора панели: выпуск и проверка токена. // // Реализаций в панелях было две — подписанный JWT через jsonwebtoken и // самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но // краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке. // jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели. // // Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому // не может их отменить — логаут по умолчанию чисто клиентский. Опция // `revocable` включает список отозванных подписей в памяти, и тогда логаут // действительно убивает токен. Цена: список не переживает рестарт и не общий // для нескольких инстансов за балансировщиком — после рестарта отозванный // токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на // нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай // свой store вместо встроенного. import jwt from 'jsonwebtoken'; export interface SessionClaims { user: string; userId: string; role: string; permissions: string[]; } /** Хранилище отозванных токенов. По умолчанию — Set в памяти. */ export interface RevocationStore { revoke: (signature: string) => void; isRevoked: (signature: string) => boolean; } export interface SessionOptions { /** Секрет подписи. Свой, отдельный от секретов основного продукта. */ secret: string; /** Время жизни токена. По умолчанию 12 часов. */ ttl?: string | number; /** Включить отзыв токенов на логауте. По умолчанию выключено. */ revocable?: boolean; /** Своё хранилище отозванных — например, общее на несколько инстансов. */ revocationStore?: RevocationStore; } export interface SessionStore { issue: (claims: SessionClaims) => string; /** Клеймы живого токена, либо null — протух, подделан или отозван. */ verify: (token: string) => SessionClaims | null; /** Отозвать токен (если отзыв включён). */ revoke: (token: string) => void; revocable: boolean; } function memoryRevocationStore(): RevocationStore { const revoked = new Set(); return { revoke: (sig) => { revoked.add(sig); }, isRevoked: (sig) => revoked.has(sig), }; } // Подпись однозначно идентифицирует токен и короче его самого. const signatureOf = (token: string) => token.split('.')[2] ?? token; export function createSessionStore(options: SessionOptions): SessionStore { const { secret } = options; if (!secret) { throw new Error( 'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' + ' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.', ); } const ttl = options.ttl ?? '12h'; const revocable = options.revocable ?? false; const store = options.revocationStore ?? memoryRevocationStore(); return { revocable, issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions), verify(token) { if (!token) return null; if (revocable && store.isRevoked(signatureOf(token))) return null; try { // algorithms задан явно: без него подошёл бы токен, подписанный // другим алгоритмом из заголовка самого токена. const claims = jwt.verify(token, secret, { algorithms: ['HS256'] }); return claims as SessionClaims & jwt.JwtPayload; } catch { return null; } }, revoke(token) { if (revocable && token) store.revoke(signatureOf(token)); }, }; }