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