import { Router } from "express"; import { z } from 'zod'; import crypto from 'crypto'; import { storage } from "../storage"; import { authService, addUserToDefaultGroup, setAuthCookies, clearAuthCookies } from "../services/auth.service"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { registerOrganizationSchema, loginSchema, registerByInvitationSchema } from "@shared/schema"; import { hashPassword, verifyPassword } from "../utils/password"; import { hashToken } from "../utils/jwt"; import { emailService } from "../services/email.service"; import { notificationService } from "../services/notification.service"; import { authLimiter, refreshLimiter } from "./shared"; import { logAudit, getClientIp } from "../utils/audit"; import { sanitizeReturnTo } from "../utils/return-to"; import { getLoginLockRemainingMs, recordFailedLoginAttempt, resetLoginAttempts, } from "../utils/login-attempts"; // Единый ответ forgot-password: не раскрывает, существует ли аккаунт. const FORGOT_PASSWORD_MESSAGE = 'Если аккаунт с таким email существует, письмо со ссылкой для сброса пароля отправлено'; // Текст при временной блокировке после серии неудачных попыток входа. const LOGIN_LOCKED_MESSAGE = 'Слишком много неудачных попыток входа. Попробуйте снова через 15 минут'; export function registerAuthCoreRoutes(router: Router): void { /** * @swagger * /api/auth/register-organization: * post: * tags: [Auth] * summary: Регистрация организации * security: [] * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/RegisterOrganizationInput' * responses: * 201: * description: Организация создана, письмо с подтверждением отправлено * 400: * description: Ошибка валидации или slug уже занят */ router.post('/api/auth/register-organization', authLimiter, validateRequest(registerOrganizationSchema), async (req, res) => { try { const result = await authService.registerOrganization(req.body); if (result.success) { res.status(201).json({ success: true, message: result.message }); } else { res.status(400).json({ success: false, error: result.error }); } } catch (error) { console.error('Registration error:', error); res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } } ); /** * @swagger * /api/auth/login: * post: * tags: [Auth] * summary: Вход в систему * security: [] * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/LoginInput' * responses: * 200: * description: Успешный вход * 401: * description: Неверные учётные данные */ router.post('/api/auth/login', authLimiter, validateRequest(loginSchema), async (req, res) => { try { const email: string = req.body?.email ?? ''; // Per-account лимит: после серии неудачных попыток по этому email // вход временно блокируется (защита от brute-force конкретного аккаунта). if (getLoginLockRemainingMs(email) > 0) { logAudit({ action: 'auth.login.locked', details: { email }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); return res.status(401).json({ success: false, error: LOGIN_LOCKED_MESSAGE }); } const result = await authService.login(req.body, req); if (result.success && result.user && result.tokens) { resetLoginAttempts(email); logAudit({ action: 'auth.login.success', userId: result.user.id, organizationId: result.user.organization.id, details: { email: result.user.email }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); setAuthCookies(res, result.tokens, req.body.remember ?? false); res.json({ success: true, user: { id: result.user.id, email: result.user.email, firstName: result.user.firstName, lastName: result.user.lastName, middleName: result.user.middleName, appRole: result.user.appRole, permissions: (result.user as any).permissions || [], organization: { id: result.user.organization.id, name: result.user.organization.name, slug: result.user.organization.slug, displayName: result.user.organization.displayName } }, tokens: result.tokens }); } else { const attempt = recordFailedLoginAttempt(email); if (attempt.justLocked) { logAudit({ action: 'auth.login.locked', details: { email, reason: 'max_failed_attempts' }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); return res.status(401).json({ success: false, error: LOGIN_LOCKED_MESSAGE }); } logAudit({ action: 'auth.login.failed', details: { email, reason: result.error }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); res.status(401).json({ success: false, error: result.error }); } } catch (error) { console.error('Login error:', error); res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } } ); router.post('/api/auth/refresh', refreshLimiter, async (req, res) => { try { const refreshToken = req.cookies?.refresh_token || req.body?.refreshToken; if (!refreshToken) { return res.status(400).json({ success: false, error: 'Refresh token отсутствует' }); } const result = await authService.refreshToken(refreshToken); if (result.success && result.tokens && result.user) { // Prefer the session's remember flag; fall back to the request body for legacy/native clients. const remember = result.remember ?? req.body?.remember ?? false; setAuthCookies(res, result.tokens, remember); res.json({ success: true, tokens: result.tokens, user: { id: result.user.id, email: result.user.email, firstName: result.user.firstName, lastName: result.user.lastName, middleName: result.user.middleName, appRole: result.user.appRole, permissions: (result.user as any).permissions || [], organization: { id: result.user.organization.id, name: result.user.organization.name, slug: result.user.organization.slug, displayName: result.user.organization.displayName } } }); } else { // clearCookies === false: неоднозначный отказ (гонка ротации, successor // не найден) — cookie не трогаем, у клиента может быть живая winner-cookie. if (result.clearCookies !== false) { clearAuthCookies(res); } res.status(401).json({ success: false, error: result.error }); } } catch (error) { console.error('Refresh token error:', error); res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } }); router.post('/api/auth/logout', authenticateToken, async (req: AuthenticatedRequest, res) => { try { const refreshToken = req.cookies?.refresh_token || req.body?.refreshToken; if (refreshToken) { await authService.logout(refreshToken); } clearAuthCookies(res); logAudit({ action: 'auth.logout', userId: req.user?.id ?? null, organizationId: req.organizationId ?? null, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); res.json({ success: true, message: 'Успешный выход' }); } catch (error) { console.error('Logout error:', error); res.status(500).json({ success: false, error: 'Ошибка при выходе' }); } }); // GET /api/auth/me — return current authenticated user router.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res) => { try { const user = req.user; if (!user) { return res.status(401).json({ success: false, error: 'Пользователь не найден' }); } const permissions = await storage.getAppRolePermissions(user.appRole); // Эффективный доступ к модулям (GPS/Финансы) — флаг пользователя OR роли OR admin const { hasModuleAccess } = await import("../utils/module-access"); const moduleAccess = { gps: await hasModuleAccess(user as any, "gps"), finance: await hasModuleAccess(user as any, "finance"), }; res.json({ success: true, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, middleName: user.middleName, appRole: user.appRole, permissions, moduleAccess, organization: user.organization ? { id: user.organization.id, name: user.organization.name, slug: user.organization.slug, displayName: user.organization.displayName, activeSkinId: user.organization.activeSkinId, medscheduleEnabled: user.organization.medscheduleEnabled ?? false } : null } }); } catch (error) { console.error('Get me error:', error); res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } }); /** * @swagger * /api/auth/register-by-invitation: * post: * tags: [Auth, Invitations] * summary: Регистрация по приглашению * security: [] */ router.post('/api/auth/register-by-invitation', authLimiter, validateRequest(registerByInvitationSchema), async (req, res) => { try { const { token, firstName, lastName, middleName, password, position } = req.body; const invitation = await storage.getInvitationByToken(token); if (!invitation) { return res.status(404).json({ success: false, error: 'Приглашение не найдено' }); } if (invitation.usedAt) { return res.status(400).json({ success: false, error: 'Приглашение уже использовано' }); } if (new Date(invitation.expiresAt) < new Date()) { return res.status(400).json({ success: false, error: 'Срок действия приглашения истёк' }); } const existingUser = await storage.getUserByEmail(invitation.email, invitation.organizationId); if (existingUser) { return res.status(400).json({ success: false, error: 'Пользователь с таким email уже существует' }); } const passwordHash = await hashPassword(password); const user = await storage.createUser({ organizationId: invitation.organizationId, email: invitation.email, passwordHash, firstName, lastName, middleName, position: position || null, appRole: 'user', isActive: true, emailVerified: true, }); await addUserToDefaultGroup(user.id, invitation.organizationId); notificationService.createDefaultSubscriptions(user.id, invitation.organizationId).catch(err => console.error('[Auth] Failed to create default notification subscriptions:', err) ); await storage.markInvitationAsUsed(token); res.status(201).json({ success: true, message: 'Регистрация успешна! Теперь вы можете войти в систему.' }); } catch (error) { console.error('Register by invitation error:', error); res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } } ); // POST /api/auth/forgot-password router.post('/api/auth/forgot-password', authLimiter, async (req, res) => { try { const { email, returnTo } = req.body; if (!email) { return res.status(400).json({ success: false, error: 'Необходим email' }); } const user = await storage.getUserByEmail(email); if (!user) { // Ответ идентичен случаю существующего email — anti-enumeration. return res.json({ success: true, message: FORGOT_PASSWORD_MESSAGE }); } const organization = await storage.getOrganization(user.organizationId); const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 60 * 60 * 1000); // Шаг 0.6: в БД сохраняется только sha256-хэш токена, plain — только в письме. await storage.updateUser(user.id, { resetPasswordToken: null, resetPasswordTokenHash: hashToken(token), resetPasswordExpires: expires, }); // returnTo принимаем только как относительный путь (open redirect защита); // /login исключаем как раньше, чтобы не было циклов редиректов. const sanitized = sanitizeReturnTo(returnTo); const safeReturnTo = sanitized && !sanitized.startsWith('/login') ? sanitized : undefined; await emailService.sendPasswordResetEmail( user.email, user.firstName, token, organization?.displayName || organization?.name || 'Организация', safeReturnTo ); return res.json({ success: true, message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error('Forgot password error:', error); return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } }); // POST /api/auth/reset-password router.post('/api/auth/reset-password', authLimiter, async (req, res) => { try { const { token, password } = req.body; if (!token || !password) { return res.status(400).json({ success: false, error: 'Необходимы token и password' }); } if (password.length < 8) { return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' }); } const user = await storage.getUserByResetPasswordToken(token); if (!user) { return res.status(400).json({ success: false, error: 'Неверная или устаревшая ссылка для сброса пароля' }); } if (!user.resetPasswordExpires || new Date() > new Date(user.resetPasswordExpires)) { await storage.updateUser(user.id, { resetPasswordToken: null, resetPasswordTokenHash: null, resetPasswordExpires: null }); return res.status(400).json({ success: false, error: 'Срок действия ссылки истёк. Запросите новую.' }); } const passwordHash = await hashPassword(password); await storage.updateUser(user.id, { passwordHash, resetPasswordToken: null, resetPasswordTokenHash: null, resetPasswordExpires: null, }); // При сбросе пароля инвалидируем все сессии пользователя — украденные // refresh token'ы больше не смогут продлевать доступ. await authService.invalidateAllUserSessions(user.id); clearAuthCookies(res); return res.json({ success: true }); } catch (error) { console.error('Reset password error:', error); return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' }); } }); // POST /api/auth/change-password — self-service password change router.post('/api/auth/change-password', authenticateToken, validateRequest(z.object({ currentPassword: z.string().min(1, 'Текущий пароль обязателен'), newPassword: z.string().min(1, 'Новый пароль обязателен') })), async (req: AuthenticatedRequest, res) => { try { const userId = req.user!.id; const user = await storage.getUser(userId); if (!user) { return res.status(404).json({ success: false, error: 'Пользователь не найден' }); } const isCurrentValid = await verifyPassword(req.body.currentPassword, user.passwordHash); if (!isCurrentValid) { return res.status(400).json({ success: false, error: 'Неверный текущий пароль' }); } const passwordHash = await hashPassword(req.body.newPassword); await storage.updateUser(userId, { passwordHash, updatedAt: new Date() }); await authService.invalidateAllUserSessions(userId); clearAuthCookies(res); logAudit({ action: 'auth.password.changed', userId, organizationId: user.organizationId, details: { self: true }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); return res.json({ success: true, message: 'Пароль изменен' }); } catch (error) { console.error('Change password error:', error); if (error instanceof Error && error.message.includes('Пароль должен')) { return res.status(400).json({ success: false, error: error.message }); } return res.status(500).json({ success: false, error: 'Ошибка при изменении пароля' }); } } ); }