import { storage } from '../storage'; import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password'; 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'; import { notificationService } from './notification.service'; import { conversations, conversationMembers } from '@shared/schema'; import { eq, and, desc, gte } from 'drizzle-orm'; import crypto from 'crypto'; import type { Request, Response } from 'express'; import { getClientIp } from '../utils/audit'; export async function addUserToDefaultGroup(userId: number, organizationId: number): Promise { const [defaultGroup] = await db .select({ id: conversations.id }) .from(conversations) .where(and( eq(conversations.organizationId, organizationId), eq(conversations.isDefault, true), )); if (!defaultGroup) return; await db .insert(conversationMembers) .values({ conversationId: defaultGroup.id, userId }) .onConflictDoNothing(); } export interface AuthResponse { success: boolean; user?: UserWithOrganization; tokens?: TokenPair; remember?: boolean; message?: string; error?: string; // false = при отказе не стирать auth-cookie (временная/неоднозначная ситуация, // у клиента может быть валидная cookie successor-сессии). clearCookies?: boolean; } export const ACCESS_TOKEN_COOKIE = 'access_token'; export const REFRESH_TOKEN_COOKIE = 'refresh_token'; const SESSION_LIFETIME_DAYS = 30; const REMEMBER_LIFETIME_DAYS = 90; const RACE_TOLERANCE_MS = 300_000; // 5 минут — защита от rotation-гонок между фоновыми вкладками/PWA // Фиктивный bcrypt-хэш (cost 12) для constant-time ответа при входе с // несуществующим email — см. anti-enumeration в login(). const DUMMY_PASSWORD_HASH = '$2b$12$AKA6k01zovdEWcbsfyUu6eYQyakr7Z.COfTbva8MXWD4jn5u9OgQy'; function isSecureCookie(): boolean { // In production we always serve over HTTPS behind Traefik. return process.env.NODE_ENV === 'production'; } export function setAuthCookies(res: Response, tokens: TokenPair, remember: boolean): void { const accessMaxAge = parseExpiryToSeconds(ACCESS_TOKEN_EXPIRY) * 1000; const refreshMaxAge = (remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS) * 24 * 60 * 60 * 1000; res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, { httpOnly: true, secure: isSecureCookie(), sameSite: 'strict', maxAge: accessMaxAge, path: '/', }); res.cookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, { httpOnly: true, secure: isSecureCookie(), sameSite: 'strict', maxAge: refreshMaxAge, path: '/api/auth', }); } export function clearAuthCookies(res: Response): void { res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' }); res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/api/auth' }); } export class AuthService { async registerOrganization(data: RegisterOrganization): Promise { return withSuperAdmin(async () => { try { const existingOrg = await storage.getOrganizationBySlug(data.organizationSlug); if (existingOrg) { return { success: false, error: 'Организация с таким кодом уже существует' }; } const existingUser = await storage.getUserByEmail(data.adminEmail); if (existingUser) { return { success: false, error: 'Пользователь с таким email уже существует' }; } const organization = await storage.createOrganization({ name: data.organizationName, slug: data.organizationSlug, displayName: data.organizationName, isActive: true }); // Create default OpenStreetMap service for the new organization await storage.createExternalService({ organizationId: organization.id, serviceType: 'openstreetmap', serviceName: 'OpenStreetMap (Nominatim)', apiKey: null, secretKey: null, config: null, isActive: true, }).catch((err: unknown) => { console.error('[Auth] Failed to create default OpenStreetMap service:', err); }); const passwordHash = await hashPassword(data.adminPassword); const adminUser = await storage.createUser({ organizationId: organization.id, email: data.adminEmail, passwordHash, firstName: data.adminFirstName, lastName: data.adminLastName, middleName: (data as any).adminMiddleName, appRole: 'admin', isActive: true, emailVerified: true, }); notificationService.createDefaultSubscriptions(adminUser.id, organization.id).catch(err => console.error('[Auth] Failed to create default notification subscriptions:', err) ); const [defaultConv] = await db .insert(conversations) .values({ organizationId: organization.id, type: 'group', name: 'Наша Фирма', createdBy: adminUser.id, isDefault: true, }) .returning(); await db.insert(conversationMembers).values({ conversationId: defaultConv.id, userId: adminUser.id }); return { success: true, message: 'Организация создана успешно! Теперь вы можете войти в систему.' }; } catch (error) { console.error('Registration error:', error); return { success: false, error: 'Ошибка при создании организации' }; } }); } private async createSession( user: UserWithOrganization, remember: boolean, req?: Request ): Promise { const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS; const familyId = crypto.randomUUID(); const now = Date.now(); const familyCreatedAt = new Date(now); const expiresAt = new Date(now + tokenLifetimeDays * 24 * 60 * 60 * 1000); const tokens = generateTokens({ userId: user.id, organizationId: user.organizationId, appRole: user.appRole }, remember, familyId); // Шаг 0.6: в БД сохраняется только sha256-хэш refresh-токена, plain не хранится. const session = await storage.createUserSession({ userId: user.id, refreshTokenHash: hashToken(tokens.refreshToken), familyId, familyCreatedAt, expiresAt, remember, ipAddress: req ? getClientIp(req) : null, userAgent: req?.headers['user-agent'] ?? null, }); // Access-токен перевыпускаем с sid — для отзыва устройства в моменте tokens.accessToken = signAccessToken({ userId: user.id, organizationId: user.organizationId, appRole: user.appRole, }, session.id); return tokens; } async login(data: LoginRequest, req?: Request): Promise { return withSuperAdmin(async () => { try { let user: UserWithOrganization | undefined; if (data.organizationSlug) { user = await storage.getUserByEmailAndSlug(data.email, data.organizationSlug); } else { const foundUser = await storage.getUserByEmail(data.email); if (foundUser) { user = await storage.getUserWithOrganization(foundUser.id); } } // Anti-enumeration: bcrypt-сравнение выполняется всегда — при // несуществующем пользователе с захардкоженным хэшем, чтобы по // времени ответа нельзя было отличить состояние аккаунта. // Текст ошибки единый для «не найден» / «неактивен» / «неверный пароль». const isValidPassword = await verifyPassword( data.password, user?.passwordHash ?? DUMMY_PASSWORD_HASH ); if (!user || !user.isActive || !isValidPassword) { return { success: false, error: 'Неверный email или пароль' }; } if (user.organization && !user.organization.isActive) { return { success: false, error: 'Доступ организации заблокирован' }; } const remember = data.remember ?? false; const tokens = await this.createSession(user, remember, req); await storage.updateUser(user.id, { lastLogin: new Date() }); const permissions = await storage.getAppRolePermissions(user.appRole); return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, tokens }; } catch (error) { console.error('Login error:', error); return { success: false, error: 'Ошибка при входе в систему' }; } }); } async createUser(organizationId: number, userData: { email: string; firstName: string; lastName: string; middleName?: string; position?: string; appRole: string; organizationalRoleIds?: number[]; sendInvite: boolean; password?: string; statusId?: number; customFields?: Array<{ fieldId: number; value: string | null }>; }): Promise<{ success: boolean; user?: User; message?: string; error?: string }> { return withSuperAdmin(async () => { try { const existingUser = await storage.getUserByEmail(userData.email, organizationId); if (existingUser) { return { success: false, error: 'Пользователь с таким email уже существует в организации' }; } const tempPassword = userData.password ?? generateTempPassword(); const passwordHash = await hashPassword(tempPassword); const user = await storage.createUser({ organizationId, email: userData.email, passwordHash, firstName: userData.firstName, lastName: userData.lastName, middleName: userData.middleName, position: userData.position, appRole: userData.appRole, statusId: userData.statusId, isActive: true, emailVerified: true }); // Assign organizational roles if provided if (userData.organizationalRoleIds && userData.organizationalRoleIds.length > 0) { await storage.setUserOrganizationalRoles(user.id, userData.organizationalRoleIds); } // Set custom fields if provided if (userData.customFields && userData.customFields.length > 0) { await storage.setUserCustomValues(user.id, userData.customFields, organizationId); } await addUserToDefaultGroup(user.id, organizationId); notificationService.createDefaultSubscriptions(user.id, organizationId).catch(err => console.error('[Auth] Failed to create default notification subscriptions:', err) ); return { success: true, user, message: userData.password ? 'Пользователь создан с указанным паролем' : (userData.sendInvite ? `Пользователь создан. Временный пароль: ${tempPassword}. Сообщите его новому пользователю.` : 'Пользователь создан') }; } catch (error) { console.error('Create user error:', error); // Ошибки валидации пароля из hashPassword отдаём клиенту как есть if (error instanceof Error && error.message.includes('Пароль должен')) { return { success: false, error: error.message }; } return { success: false, error: 'Ошибка при создании пользователя' }; } }); } // Ротация refresh-токена внутри family: текущая сессия помечается заменённой, // создаётся новая с новой парой токенов. expiresAt ограничен потолком family. // В БД пишутся только sha256-хэши токенов (шаг 0.6). private async rotateFamilySession( session: UserSession, user: UserWithOrganization ): Promise<{ tokens: TokenPair; remember: boolean }> { const remember = session.remember ?? false; const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS; const msLifetime = tokenLifetimeDays * 24 * 60 * 60 * 1000; const familyCreatedAt = new Date(session.familyCreatedAt).getTime(); const maxExpiresAt = new Date(familyCreatedAt + msLifetime); const proposedExpiresAt = new Date(Date.now() + msLifetime); const expiresAt = proposedExpiresAt < maxExpiresAt ? proposedExpiresAt : maxExpiresAt; const tokens = generateTokens({ userId: user.id, organizationId: user.organizationId, appRole: user.appRole }, remember, session.familyId); // Атомарная ротация: replaced + insert новой сессии в одной транзакции. // userAgent/ipAddress переносим из исходной сессии — иначе rotated-строки // отображались бы в списке устройств как «Неизвестное устройство». const newSession = await storage.rotateUserSession(session.id, { userId: user.id, refreshTokenHash: hashToken(tokens.refreshToken), familyId: session.familyId, familyCreatedAt: session.familyCreatedAt, parentRefreshTokenHash: session.refreshTokenHash, expiresAt, remember, ipAddress: session.ipAddress, userAgent: session.userAgent, }); // Access-токен перевыпускаем с sid — для отзыва устройства в моменте tokens.accessToken = signAccessToken({ userId: user.id, organizationId: user.organizationId, appRole: user.appRole, }, newSession.id); return { tokens, remember }; } // Обработка повторного использования уже сменённого (rotated) refresh-токена. // rotated — successor-сессия, найденная по parentRefreshTokenHash. // В пределах RACE_TOLERANCE_MS это гонка вкладок/PWA: ротируем активную // сессию family и выдаём новую пару (200). Если активной сессии нет — // отказ БЕЗ стирания cookie и БЕЗ отзыва family. После окна — настоящий // reuse-attack: отзыв всей family. private async handleRotatedToken(rotated: UserSession): Promise { if (rotated.isRevoked) { console.warn(`[Auth] Refresh denied: rotated session ${rotated.id} is revoked (family ${rotated.familyId})`); return { success: false, error: 'Недействительный или истекший токен' }; } const rotatedAt = rotated.createdAt ? new Date(rotated.createdAt).getTime() : 0; if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) { // В grace-окне: plain-токены в БД не хранятся (шаг 0.6), вернуть «тот же» // refresh нельзя — вместо этого активная сессия ротируется и клиент // получает новую пару. const activeSession = await storage.getActiveSessionInFamily(rotated.familyId); if (activeSession) { const activeUser = await storage.getUserWithOrganization(activeSession.userId); if (activeUser && activeUser.isActive) { const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser); return { success: true, user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any, remember, tokens, }; } } // Successor есть, но активной сессии не нашлось — неоднозначная ситуация. // Cookie не трогаем и family не отзываем: у клиента может быть валидная // cookie winner-запроса, он повторит рефреш ею. console.warn(`[Auth] Refresh in grace window but no active session in family ${rotated.familyId}`); return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; } // Reuse detected (за пределами grace-окна): revoke the whole family. console.warn(`[Auth] Refresh token reuse detected for family ${rotated.familyId}, rotated session ${rotated.id}`); await storage.revokeSessionFamily(rotated.familyId); return { success: false, error: 'Недействительный или истекший токен' }; } async refreshToken(refreshToken: string): Promise { return withSuperAdmin(async () => { try { const session = await storage.getUserSessionByToken(refreshToken); // Token not found as current — maybe it was already rotated (race or reuse). if (!session) { const rotatedSession = await storage.getUserSessionByParentToken(refreshToken); if (rotatedSession) { return this.handleRotatedToken(rotatedSession); } // Токен вообще неизвестен — cookie не стираем (у клиента может оставаться // живой access-cookie), family-ревока не для чего вызывать. console.warn('[Auth] Refresh denied: token not found as current or rotated'); return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; } if (session.isRevoked) { console.warn(`[Auth] Refresh denied: session ${session.id} is revoked (family ${session.familyId})`); return { success: false, error: 'Недействительный или истекший токен' }; } if (session.isReplaced) { // Гонка ротации: этот токен уже заменён, но его hash в БД сохранён — // ищем successor-сессию и решаем по grace-окну. const successor = await storage.getUserSessionByParentToken(refreshToken); if (successor) { return this.handleRotatedToken(successor); } // Successor не найден (например, запись не дочитана репликой/удалена) — // 401 БЕЗ стирания cookie и БЕЗ revoke family. console.warn(`[Auth] Refresh denied: session ${session.id} is replaced but no successor found (family ${session.familyId})`); return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; } if (session.expiresAt < new Date()) { console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`); return { success: false, error: 'Недействительный или истекший токен' }; } const decoded = verifyRefreshToken(refreshToken); if (decoded.userId !== session.userId) { console.warn(`[Auth] Refresh denied: token userId ${decoded.userId} does not match session userId ${session.userId}`); await storage.revokeSessionFamily(session.familyId); return { success: false, error: 'Недействительный токен' }; } const user = await storage.getUserWithOrganization(session.userId); if (!user || !user.isActive) { console.warn(`[Auth] Refresh denied: user ${session.userId} not found or inactive`); return { success: false, error: 'Пользователь не найден или заблокирован' }; } if (user.organization && !user.organization.isActive) { console.warn(`[Auth] Refresh denied: organization ${user.organizationId} inactive`); return { success: false, error: 'Доступ организации заблокирован' }; } const { tokens, remember } = await this.rotateFamilySession(session, user); const permissions = await storage.getAppRolePermissions(user.appRole); return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, remember, tokens }; } catch (error) { console.error('[Auth] Refresh token error:', error); return { success: false, error: 'Ошибка при обновлении токена' }; } }); } async logout(refreshToken: string): Promise { try { await withSuperAdmin(() => storage.revokeSession(refreshToken)); } catch (error) { console.error('Logout error:', error); } } async invalidateAllUserSessions(userId: number): Promise { try { await withSuperAdmin(() => storage.revokeAllUserSessions(userId)); } catch (error) { console.error('Invalidate all sessions error:', error); } } } export const authService = new AuthService();