ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Сессия оператора панели: выпуск и проверка токена.
|
||||
//
|
||||
// Реализаций в панелях было две — подписанный 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user