Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
94 lines
4.8 KiB
TypeScript
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));
|
|
},
|
|
};
|
|
}
|