feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid
This commit is contained in:
@@ -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'],
|
||||
|
||||
30
server/utils/sessionRevocation.ts
Normal file
30
server/utils/sessionRevocation.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user