diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 5fd689d..8113aba 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction } from 'express'; import { verifyAccessToken, verifyBotServiceToken, verifySuperAdminToken } from '../utils/jwt'; +import { isSessionRevoked } from '../utils/sessionRevocation'; import { storage } from '../storage'; import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db'; import { eq } from 'drizzle-orm'; @@ -70,6 +71,15 @@ export const authenticateToken = async ( if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } + // Отзыв устройства: сессия из claim sid отозвана → токен недействителен + // (проверка с кэшем ~30 сек, см. utils/sessionRevocation) + if (decoded.sid && await isSessionRevoked(decoded.sid)) { + return res.status(401).json({ error: 'Сессия отозвана' }); + } + // sid сессии — для пометки «текущее устройство» в списке сессий + if (decoded.sid) { + (req as any).sessionId = decoded.sid; + } // Use JWT organizationId to set tenant context for the users table lookup, // preventing auth failure when FORCE RLS is active on the users table. const user = await withTenant(decoded.organizationId, () => diff --git a/server/routes/auth.users.routes.ts b/server/routes/auth.users.routes.ts index 163160b..e9fab65 100644 --- a/server/routes/auth.users.routes.ts +++ b/server/routes/auth.users.routes.ts @@ -9,6 +9,7 @@ import { createUserCustomFieldSchema, updateUserCustomFieldSchema, setUserCustomValueSchema, } from "@shared/schema"; +import { logAudit, getClientIp } from "../utils/audit"; export function registerUserManagementRoutes(router: Router): void { // === User Custom Fields routes === @@ -648,4 +649,79 @@ export function registerUserManagementRoutes(router: Router): void { } } ); + + // Активные сессии пользователя (устройства) — админ или сам пользователь + router.get('/api/users/:userId/sessions', + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const userId = parseInt(req.params.userId); + if (isNaN(userId)) { + return res.status(400).json({ success: false, error: 'Неверный ID пользователя' }); + } + const targetUser = await storage.getUser(userId); + if (!targetUser || targetUser.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Пользователь не найден' }); + } + const appRole = req.user?.appRole; + if (appRole !== 'admin' && req.user?.id !== userId) { + return res.status(403).json({ success: false, error: 'Недостаточно прав' }); + } + const sessions = await storage.listUserSessions(userId); + const currentSid = (req as any).sessionId ?? null; + res.json({ + success: true, + sessions: sessions.map(s => ({ + id: s.id, + createdAt: s.createdAt, + expiresAt: s.expiresAt, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + isCurrent: s.id === currentSid, + })), + }); + } catch (error) { + console.error('List user sessions error:', error); + res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } + } + ); + + // Отзыв сессии (устройства) — своя или любая (админ) + router.delete('/api/auth/sessions/:id', + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ success: false, error: 'Неверный ID сессии' }); + } + const session = await storage.getUserSessionById(id); + if (!session) { + return res.status(404).json({ success: false, error: 'Сессия не найдена' }); + } + const targetUser = await storage.getUser(session.userId); + if (!targetUser || targetUser.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Сессия не найдена' }); + } + const appRole = req.user?.appRole; + if (appRole !== 'admin' && req.user?.id !== session.userId) { + return res.status(403).json({ success: false, error: 'Недостаточно прав' }); + } + await storage.revokeSessionById(id); + logAudit({ + action: 'auth.session.revoked', + userId: req.user!.id, + organizationId: req.organizationId, + details: { sessionId: id, targetUserId: session.userId }, + ip: getClientIp(req), + }); + res.json({ success: true }); + } catch (error) { + console.error('Revoke session error:', error); + res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); + } + } + ); } diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index dac85e2..df519df 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -1,6 +1,6 @@ import { storage } from '../storage'; import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password'; -import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, hashToken, ACCESS_TOKEN_EXPIRY, type TokenPair } from '../utils/jwt'; +import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, hashToken, signAccessToken, ACCESS_TOKEN_EXPIRY, type TokenPair } from '../utils/jwt'; import { emailService } from './email.service'; import type { RegisterOrganization, LoginRequest, User, UserWithOrganization, UserSession } from '@shared/schema'; import { db, withSuperAdmin } from '../db'; @@ -170,7 +170,7 @@ export class AuthService { }, remember, familyId); // Шаг 0.6: в БД сохраняется только sha256-хэш refresh-токена, plain не хранится. - await storage.createUserSession({ + const session = await storage.createUserSession({ userId: user.id, refreshTokenHash: hashToken(tokens.refreshToken), familyId, @@ -181,6 +181,13 @@ export class AuthService { userAgent: req?.headers['user-agent'] ?? null, }); + // Access-токен перевыпускаем с sid — для отзыва устройства в моменте + tokens.accessToken = signAccessToken({ + userId: user.id, + organizationId: user.organizationId, + appRole: user.appRole, + }, session.id); + return tokens; } @@ -323,8 +330,8 @@ export class AuthService { appRole: user.appRole }, remember, session.familyId); - await storage.markSessionReplaced(session.id, tokens.refreshToken); - await storage.createUserSession({ + // Атомарная ротация: replaced + insert новой сессии в одной транзакции + const newSession = await storage.rotateUserSession(session.id, { userId: user.id, refreshTokenHash: hashToken(tokens.refreshToken), familyId: session.familyId, @@ -334,6 +341,13 @@ export class AuthService { remember, }); + // Access-токен перевыпускаем с sid — для отзыва устройства в моменте + tokens.accessToken = signAccessToken({ + userId: user.id, + organizationId: user.organizationId, + appRole: user.appRole, + }, newSession.id); + return { tokens, remember }; } diff --git a/server/storage.ts b/server/storage.ts index 94ff445..8625c03 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -70,6 +70,10 @@ export interface IStorage { getUserSessionByToken(refreshToken: string): Promise; getUserSessionByParentToken(parentRefreshToken: string): Promise; getActiveSessionInFamily(familyId: string): Promise; + rotateUserSession(sessionId: number, newSession: InsertUserSession): Promise; + listUserSessions(userId: number): Promise; + getUserSessionById(id: number): Promise; + revokeSessionById(id: number): Promise; markSessionReplaced(sessionId: number, replacedByToken: string): Promise; revokeSession(refreshToken: string): Promise; revokeSessionFamily(familyId: string): Promise; diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index bee7951..b78e263 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm"; import crypto from "crypto"; import { hashToken } from "../utils/jwt"; +import { invalidateSessionRevocationCache } from "../utils/sessionRevocation"; import { userStatusesStorage } from "./user-statuses.storage"; import { invalidateAccessibleTasksForUser } from "../utils/cache"; @@ -339,11 +340,52 @@ export class UsersStorage { .where(eq(userSessions.id, sessionId)); } + // Атомарная ротация: старая сессия заменяется + создаётся новая в одной транзакции. + // Без этого при сбое вставки оставалась replaced-сессия без successor («no successor found»). + async rotateUserSession(sessionId: number, newSession: InsertUserSession): Promise { + return db.transaction(async (tx) => { + await tx + .update(userSessions) + .set({ isReplaced: true, replacedByTokenHash: newSession.refreshTokenHash, replacedByToken: null }) + .where(eq(userSessions.id, sessionId)); + const [row] = await tx.insert(userSessions).values(newSession).returning(); + return row; + }); + } + + // Активные сессии пользователя (для управления устройствами) + async listUserSessions(userId: number): Promise { + return db + .select() + .from(userSessions) + .where(and( + eq(userSessions.userId, userId), + eq(userSessions.isRevoked, false), + gte(userSessions.expiresAt, new Date()), + )) + .orderBy(desc(userSessions.createdAt)) + .limit(50); + } + + async getUserSessionById(id: number): Promise { + const [session] = await db.select().from(userSessions).where(eq(userSessions.id, id)); + return session || undefined; + } + + async revokeSessionById(id: number): Promise { + await db + .update(userSessions) + .set({ isRevoked: true }) + .where(eq(userSessions.id, id)); + invalidateSessionRevocationCache(id); + } + async revokeSession(refreshToken: string): Promise { await db .update(userSessions) .set({ isRevoked: true }) .where(eq(userSessions.refreshTokenHash, hashToken(refreshToken))); + invalidateSessionRevocationCache(); } async revokeSessionFamily(familyId: string): Promise { @@ -351,6 +393,7 @@ export class UsersStorage { .update(userSessions) .set({ isRevoked: true }) .where(eq(userSessions.familyId, familyId)); + invalidateSessionRevocationCache(); } async revokeAllUserSessions(userId: number): Promise { @@ -358,6 +401,7 @@ export class UsersStorage { .update(userSessions) .set({ isRevoked: true }) .where(eq(userSessions.userId, userId)); + invalidateSessionRevocationCache(); } /** Physical deletion kept for cleanup operations. */ diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts index 92d346e..6c6a808 100644 --- a/server/utils/jwt.ts +++ b/server/utils/jwt.ts @@ -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, 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'], diff --git a/server/utils/sessionRevocation.ts b/server/utils/sessionRevocation.ts new file mode 100644 index 0000000..cc7d1b7 --- /dev/null +++ b/server/utils/sessionRevocation.ts @@ -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(); + +export async function isSessionRevoked(sid: number): Promise { + 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); +} diff --git a/tests/token-security.test.ts b/tests/token-security.test.ts index 24edee6..b54d224 100644 --- a/tests/token-security.test.ts +++ b/tests/token-security.test.ts @@ -11,6 +11,7 @@ const mockStorage = vi.hoisted(() => ({ getUserSessionByToken: vi.fn(), getUserSessionByParentToken: vi.fn(), getActiveSessionInFamily: vi.fn(), + rotateUserSession: vi.fn(), markSessionReplaced: vi.fn(), revokeSession: vi.fn(), revokeSessionFamily: vi.fn(), @@ -182,32 +183,27 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр const session = makeSession(oldPlain); mockStorage.getUserSessionByToken.mockResolvedValue(session); - mockStorage.markSessionReplaced.mockResolvedValue(undefined); + mockStorage.rotateUserSession.mockResolvedValue({ id: 2 }); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(true); expect(result.tokens?.refreshToken).toBeTruthy(); - // NB: JWT с одинаковым payload и iat (в секундах) детерминирован — при ротации - // в ту же секунду новый токен может совпасть со старым; это свойство JWT, не логики. + // jti в payload: токены уникальны всегда, даже при ротации в ту же секунду // Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage) expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain); - // Старая сессия помечена заменённой по id, новый токен передан plain (хэширует storage) + // Атомарная ротация: replaced + insert в одной транзакции (storage.rotateUserSession) const newPlain = result.tokens!.refreshToken; - expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(session.id, newPlain); - - // Новая сессия: только хэши, связь с родителем — по хэшу старого токена - const newSessionArg = mockStorage.createUserSession.mock.calls.find( - (c) => c[0].parentRefreshTokenHash, - )?.[0]; - expect(newSessionArg).toBeTruthy(); - expect(newSessionArg.refreshTokenHash).toBe(hashToken(newPlain)); - expect(newSessionArg.parentRefreshTokenHash).toBe(hashToken(oldPlain)); + expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(session.id, expect.objectContaining({ + refreshTokenHash: hashToken(newPlain), + parentRefreshTokenHash: hashToken(oldPlain), + familyId: 'family-1', + })); + const newSessionArg = mockStorage.rotateUserSession.mock.calls[0][1]; expect(newSessionArg.refreshToken).toBeUndefined(); expect(newSessionArg.parentRefreshToken).toBeUndefined(); - expect(newSessionArg.familyId).toBe('family-1'); expect(user.id).toBe(1); }); @@ -251,14 +247,16 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр makeSession('stale-plain', { createdAt: new Date() }), ); mockStorage.getActiveSessionInFamily.mockResolvedValue(activeSession); - mockStorage.markSessionReplaced.mockResolvedValue(undefined); + mockStorage.rotateUserSession.mockResolvedValue({ id: 12 }); const result = await authService.refreshToken('stale-plain'); expect(result.success).toBe(true); // Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию expect(result.tokens?.refreshToken).toBeTruthy(); - expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken); + expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(activeSession.id, expect.objectContaining({ + refreshTokenHash: hashToken(result.tokens!.refreshToken), + })); }); it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => { @@ -273,14 +271,16 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр mockStorage.getUserSessionByToken.mockResolvedValue(replaced); mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); mockStorage.getActiveSessionInFamily.mockResolvedValue(successor); - mockStorage.markSessionReplaced.mockResolvedValue(undefined); + mockStorage.rotateUserSession.mockResolvedValue({ id: 12 }); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(true); expect(result.tokens?.refreshToken).toBeTruthy(); // Ротируется successor (активная сессия family), family НЕ отзывается - expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(successor.id, result.tokens!.refreshToken); + expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(successor.id, expect.objectContaining({ + refreshTokenHash: hashToken(result.tokens!.refreshToken), + })); expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); expect(result.clearCookies).toBeUndefined(); });