fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
411
server/routes/auth.core.routes.ts
Normal file
411
server/routes/auth.core.routes.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
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 { generateTokens } 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";
|
||||
|
||||
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 result = await authService.login(req.body, req);
|
||||
if (result.success && result.user && result.tokens) {
|
||||
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 {
|
||||
logAudit({
|
||||
action: 'auth.login.failed',
|
||||
details: { email: req.body?.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 {
|
||||
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: 'Пользователь не найден' });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
middleName: user.middleName,
|
||||
appRole: user.appRole,
|
||||
permissions: (user as any).permissions || [],
|
||||
organization: user.organization ? {
|
||||
id: user.organization.id,
|
||||
name: user.organization.name,
|
||||
slug: user.organization.slug,
|
||||
displayName: user.organization.displayName
|
||||
} : 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) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const organization = await storage.getOrganization(user.organizationId);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await storage.updateUser(user.id, {
|
||||
resetPasswordToken: token,
|
||||
resetPasswordExpires: expires,
|
||||
});
|
||||
|
||||
const safeReturnTo = returnTo && typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('/login')
|
||||
? returnTo
|
||||
: undefined;
|
||||
|
||||
await emailService.sendPasswordResetEmail(
|
||||
user.email,
|
||||
user.firstName,
|
||||
token,
|
||||
organization?.displayName || organization?.name || 'Организация',
|
||||
safeReturnTo
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} 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, resetPasswordExpires: null });
|
||||
return res.status(400).json({ success: false, error: 'Срок действия ссылки истёк. Запросите новую.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await storage.updateUser(user.id, {
|
||||
passwordHash,
|
||||
resetPasswordToken: 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: 'Ошибка при изменении пароля' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user