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'],