Files
iistwin/server/routes/auth.core.routes.ts
Ильяс Султанов d0112657b8 fix(auth): живая сессия на устройстве — рефреш на холодном старте, grace-окно ротации, без логаута при временных ошибках
- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
  отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
  выдаёт новую пару от successor вместо deny; cookie больше не стираются при
  stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
  revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
  (transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
  межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
2026-09-17 21:59:51 +03:00

467 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 'Ошибка при изменении пароля' });
}
}
);
}