Files
ma7engine/core/server/session.ts
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

94 lines
4.8 KiB
TypeScript

// Сессия оператора панели: выпуск и проверка токена.
//
// Реализаций в панелях было две — подписанный 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<string>();
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));
},
};
}