feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid

This commit is contained in:
2026-09-21 18:13:46 +03:00
parent 2f9d049718
commit c957a70260
8 changed files with 221 additions and 31 deletions

View File

@@ -31,8 +31,10 @@ 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';
// Access-токен — 24 часа (рабочий день). Рефреш-ротация и отзыв сессий
// покрывают безопасность; 15 минут давали 401-штормы и гонки ротации на
// каждом переключении вкладок. Env-переопределение сохранено.
export const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '24h';
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';
@@ -45,6 +47,8 @@ export interface TokenPayload {
organizationId: number;
appRole: string;
role?: string; // legacy fallback for old tokens
/** id сессии (user_sessions) — для отзыва устройства в моменте */
sid?: number;
}
export interface BotServiceTokenPayload {
@@ -83,16 +87,14 @@ export interface TokenPair {
familyId?: string;
}
export function generateTokens(payload: TokenPayload, remember: boolean = false, familyId?: string): TokenPair {
export function generateTokens(payload: TokenPayload, remember: boolean = false, familyId?: string, sid?: number): 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 accessToken = signAccessToken(cleanPayload, sid);
const refreshToken = jwt.sign(cleanPayload, REFRESH_TOKEN_SECRET, {
// jti гарантирует уникальность токена даже при двух ротациях в одну секунду
// (одинаковый payload+iat давал идентичные токены → конфликт unique по хэшу).
const refreshToken = jwt.sign({ ...cleanPayload, jti: crypto.randomUUID() }, REFRESH_TOKEN_SECRET, {
expiresIn: (remember ? REFRESH_TOKEN_EXPIRY_REMEMBER : REFRESH_TOKEN_EXPIRY_SESSION) as jwt.SignOptions['expiresIn'],
issuer: 'workflow-system',
audience: 'workflow-users'
@@ -106,6 +108,16 @@ export function generateTokens(payload: TokenPayload, remember: boolean = false,
};
}
/** Подпись access-токена; sid — id сессии для отзыва устройства. */
export function signAccessToken(payload: Omit<TokenPayload, 'sid'>, sid?: number): string {
const { role, ...cleanPayload } = payload as any;
return jwt.sign(sid ? { ...cleanPayload, sid } : cleanPayload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
issuer: 'workflow-system',
audience: 'workflow-users'
});
}
export function verifyAccessToken(token: string): TokenPayload {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],

View File

@@ -0,0 +1,30 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { userSessions } from '@shared/schema';
/**
* Проверка отзыва сессии (user_sessions.is_revoked) для access-токенов с claim sid.
* In-memory кэш ~30 сек: неизменный ответ не дёргает БД на каждый запрос,
* отозванная сессия умирает в течение ~30 секунд на всех инстансах приложения.
*/
const CACHE_TTL_MS = 30_000;
const cache = new Map<number, { revoked: boolean; ts: number }>();
export async function isSessionRevoked(sid: number): Promise<boolean> {
const hit = cache.get(sid);
if (hit && Date.now() - hit.ts < CACHE_TTL_MS) return hit.revoked;
const [row] = await db
.select({ isRevoked: userSessions.isRevoked })
.from(userSessions)
.where(eq(userSessions.id, sid));
// Неизвестная/удалённая сессия считается отозванной
const revoked = row ? row.isRevoked : true;
cache.set(sid, { revoked, ts: Date.now() });
return revoked;
}
/** Сброс кэша после явного отзыва (logout, отзыв устройства, смена пароля) */
export function invalidateSessionRevocationCache(sid?: number): void {
if (sid === undefined) cache.clear();
else cache.delete(sid);
}