Files
iistwin/server/routes/auth.core.routes.ts
Ильяс Султанов 0da6c04790 fix(permissions): restore admin access after page reload
- /api/auth/me now returns app-role permissions (was empty after reload).
- client hasPermission/hasAnyPermission now bypass admin so UI queries are not disabled when permissions array is stale.
2026-07-13 16:16:46 +03:00

414 lines
16 KiB
TypeScript
Raw 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 { 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: 'Пользователь не найден' });
}
const permissions = await storage.getAppRolePermissions(user.appRole);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
appRole: user.appRole,
permissions,
organization: user.organization ? {
id: user.organization.id,
name: user.organization.name,
slug: user.organization.slug,
displayName: user.organization.displayName,
activeSkinId: user.organization.activeSkinId
} : 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: 'Ошибка при изменении пароля' });
}
}
);
}