481 lines
21 KiB
TypeScript
481 lines
21 KiB
TypeScript
import { storage } from '../storage';
|
||
import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password';
|
||
import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, hashToken, signAccessToken, ACCESS_TOKEN_EXPIRY, type TokenPair } from '../utils/jwt';
|
||
import { emailService } from './email.service';
|
||
import type { RegisterOrganization, LoginRequest, User, UserWithOrganization, UserSession } from '@shared/schema';
|
||
import { db, withSuperAdmin } from '../db';
|
||
import { notificationService } from './notification.service';
|
||
import { conversations, conversationMembers } from '@shared/schema';
|
||
import { eq, and, desc, gte } from 'drizzle-orm';
|
||
import crypto from 'crypto';
|
||
import type { Request, Response } from 'express';
|
||
import { getClientIp } from '../utils/audit';
|
||
|
||
export async function addUserToDefaultGroup(userId: number, organizationId: number): Promise<void> {
|
||
const [defaultGroup] = await db
|
||
.select({ id: conversations.id })
|
||
.from(conversations)
|
||
.where(and(
|
||
eq(conversations.organizationId, organizationId),
|
||
eq(conversations.isDefault, true),
|
||
));
|
||
if (!defaultGroup) return;
|
||
await db
|
||
.insert(conversationMembers)
|
||
.values({ conversationId: defaultGroup.id, userId })
|
||
.onConflictDoNothing();
|
||
}
|
||
|
||
export interface AuthResponse {
|
||
success: boolean;
|
||
user?: UserWithOrganization;
|
||
tokens?: TokenPair;
|
||
remember?: boolean;
|
||
message?: string;
|
||
error?: string;
|
||
// false = при отказе не стирать auth-cookie (временная/неоднозначная ситуация,
|
||
// у клиента может быть валидная cookie successor-сессии).
|
||
clearCookies?: boolean;
|
||
}
|
||
|
||
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
||
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||
|
||
const SESSION_LIFETIME_DAYS = 30;
|
||
const REMEMBER_LIFETIME_DAYS = 90;
|
||
const RACE_TOLERANCE_MS = 300_000; // 5 минут — защита от rotation-гонок между фоновыми вкладками/PWA
|
||
|
||
// Фиктивный bcrypt-хэш (cost 12) для constant-time ответа при входе с
|
||
// несуществующим email — см. anti-enumeration в login().
|
||
const DUMMY_PASSWORD_HASH = '$2b$12$AKA6k01zovdEWcbsfyUu6eYQyakr7Z.COfTbva8MXWD4jn5u9OgQy';
|
||
|
||
function isSecureCookie(): boolean {
|
||
// In production we always serve over HTTPS behind Traefik.
|
||
return process.env.NODE_ENV === 'production';
|
||
}
|
||
|
||
export function setAuthCookies(res: Response, tokens: TokenPair, remember: boolean): void {
|
||
const accessMaxAge = parseExpiryToSeconds(ACCESS_TOKEN_EXPIRY) * 1000;
|
||
const refreshMaxAge = (remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS) * 24 * 60 * 60 * 1000;
|
||
|
||
res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
|
||
httpOnly: true,
|
||
secure: isSecureCookie(),
|
||
sameSite: 'strict',
|
||
maxAge: accessMaxAge,
|
||
path: '/',
|
||
});
|
||
|
||
res.cookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
|
||
httpOnly: true,
|
||
secure: isSecureCookie(),
|
||
sameSite: 'strict',
|
||
maxAge: refreshMaxAge,
|
||
path: '/api/auth',
|
||
});
|
||
}
|
||
|
||
export function clearAuthCookies(res: Response): void {
|
||
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/api/auth' });
|
||
}
|
||
|
||
export class AuthService {
|
||
async registerOrganization(data: RegisterOrganization): Promise<AuthResponse> {
|
||
return withSuperAdmin(async () => {
|
||
try {
|
||
const existingOrg = await storage.getOrganizationBySlug(data.organizationSlug);
|
||
if (existingOrg) {
|
||
return { success: false, error: 'Организация с таким кодом уже существует' };
|
||
}
|
||
|
||
const existingUser = await storage.getUserByEmail(data.adminEmail);
|
||
if (existingUser) {
|
||
return { success: false, error: 'Пользователь с таким email уже существует' };
|
||
}
|
||
|
||
const organization = await storage.createOrganization({
|
||
name: data.organizationName,
|
||
slug: data.organizationSlug,
|
||
displayName: data.organizationName,
|
||
isActive: true
|
||
});
|
||
|
||
// Create default OpenStreetMap service for the new organization
|
||
await storage.createExternalService({
|
||
organizationId: organization.id,
|
||
serviceType: 'openstreetmap',
|
||
serviceName: 'OpenStreetMap (Nominatim)',
|
||
apiKey: null,
|
||
secretKey: null,
|
||
config: null,
|
||
isActive: true,
|
||
}).catch((err: unknown) => {
|
||
console.error('[Auth] Failed to create default OpenStreetMap service:', err);
|
||
});
|
||
|
||
const passwordHash = await hashPassword(data.adminPassword);
|
||
|
||
const adminUser = await storage.createUser({
|
||
organizationId: organization.id,
|
||
email: data.adminEmail,
|
||
passwordHash,
|
||
firstName: data.adminFirstName,
|
||
lastName: data.adminLastName,
|
||
middleName: (data as any).adminMiddleName,
|
||
appRole: 'admin',
|
||
isActive: true,
|
||
emailVerified: true,
|
||
});
|
||
|
||
notificationService.createDefaultSubscriptions(adminUser.id, organization.id).catch(err =>
|
||
console.error('[Auth] Failed to create default notification subscriptions:', err)
|
||
);
|
||
|
||
const [defaultConv] = await db
|
||
.insert(conversations)
|
||
.values({
|
||
organizationId: organization.id,
|
||
type: 'group',
|
||
name: 'Наша Фирма',
|
||
createdBy: adminUser.id,
|
||
isDefault: true,
|
||
})
|
||
.returning();
|
||
await db.insert(conversationMembers).values({ conversationId: defaultConv.id, userId: adminUser.id });
|
||
|
||
return { success: true, message: 'Организация создана успешно! Теперь вы можете войти в систему.' };
|
||
} catch (error) {
|
||
console.error('Registration error:', error);
|
||
return { success: false, error: 'Ошибка при создании организации' };
|
||
}
|
||
});
|
||
}
|
||
|
||
private async createSession(
|
||
user: UserWithOrganization,
|
||
remember: boolean,
|
||
req?: Request
|
||
): Promise<TokenPair> {
|
||
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
|
||
const familyId = crypto.randomUUID();
|
||
const now = Date.now();
|
||
const familyCreatedAt = new Date(now);
|
||
const expiresAt = new Date(now + tokenLifetimeDays * 24 * 60 * 60 * 1000);
|
||
|
||
const tokens = generateTokens({
|
||
userId: user.id,
|
||
organizationId: user.organizationId,
|
||
appRole: user.appRole
|
||
}, remember, familyId);
|
||
|
||
// Шаг 0.6: в БД сохраняется только sha256-хэш refresh-токена, plain не хранится.
|
||
const session = await storage.createUserSession({
|
||
userId: user.id,
|
||
refreshTokenHash: hashToken(tokens.refreshToken),
|
||
familyId,
|
||
familyCreatedAt,
|
||
expiresAt,
|
||
remember,
|
||
ipAddress: req ? getClientIp(req) : null,
|
||
userAgent: req?.headers['user-agent'] ?? null,
|
||
});
|
||
|
||
// Access-токен перевыпускаем с sid — для отзыва устройства в моменте
|
||
tokens.accessToken = signAccessToken({
|
||
userId: user.id,
|
||
organizationId: user.organizationId,
|
||
appRole: user.appRole,
|
||
}, session.id);
|
||
|
||
return tokens;
|
||
}
|
||
|
||
async login(data: LoginRequest, req?: Request): Promise<AuthResponse> {
|
||
return withSuperAdmin(async () => {
|
||
try {
|
||
let user: UserWithOrganization | undefined;
|
||
|
||
if (data.organizationSlug) {
|
||
user = await storage.getUserByEmailAndSlug(data.email, data.organizationSlug);
|
||
} else {
|
||
const foundUser = await storage.getUserByEmail(data.email);
|
||
if (foundUser) {
|
||
user = await storage.getUserWithOrganization(foundUser.id);
|
||
}
|
||
}
|
||
|
||
// Anti-enumeration: bcrypt-сравнение выполняется всегда — при
|
||
// несуществующем пользователе с захардкоженным хэшем, чтобы по
|
||
// времени ответа нельзя было отличить состояние аккаунта.
|
||
// Текст ошибки единый для «не найден» / «неактивен» / «неверный пароль».
|
||
const isValidPassword = await verifyPassword(
|
||
data.password,
|
||
user?.passwordHash ?? DUMMY_PASSWORD_HASH
|
||
);
|
||
|
||
if (!user || !user.isActive || !isValidPassword) {
|
||
return { success: false, error: 'Неверный email или пароль' };
|
||
}
|
||
|
||
if (user.organization && !user.organization.isActive) {
|
||
return { success: false, error: 'Доступ организации заблокирован' };
|
||
}
|
||
|
||
const remember = data.remember ?? false;
|
||
const tokens = await this.createSession(user, remember, req);
|
||
|
||
await storage.updateUser(user.id, { lastLogin: new Date() });
|
||
|
||
const permissions = await storage.getAppRolePermissions(user.appRole);
|
||
return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, tokens };
|
||
} catch (error) {
|
||
console.error('Login error:', error);
|
||
return { success: false, error: 'Ошибка при входе в систему' };
|
||
}
|
||
});
|
||
}
|
||
|
||
async createUser(organizationId: number, userData: {
|
||
email: string;
|
||
firstName: string;
|
||
lastName: string;
|
||
middleName?: string;
|
||
position?: string;
|
||
appRole: string;
|
||
organizationalRoleIds?: number[];
|
||
sendInvite: boolean;
|
||
password?: string;
|
||
statusId?: number;
|
||
customFields?: Array<{ fieldId: number; value: string | null }>;
|
||
}): Promise<{ success: boolean; user?: User; message?: string; error?: string }> {
|
||
return withSuperAdmin(async () => {
|
||
try {
|
||
const existingUser = await storage.getUserByEmail(userData.email, organizationId);
|
||
if (existingUser) {
|
||
return { success: false, error: 'Пользователь с таким email уже существует в организации' };
|
||
}
|
||
|
||
const tempPassword = userData.password ?? generateTempPassword();
|
||
const passwordHash = await hashPassword(tempPassword);
|
||
|
||
const user = await storage.createUser({
|
||
organizationId,
|
||
email: userData.email,
|
||
passwordHash,
|
||
firstName: userData.firstName,
|
||
lastName: userData.lastName,
|
||
middleName: userData.middleName,
|
||
position: userData.position,
|
||
appRole: userData.appRole,
|
||
statusId: userData.statusId,
|
||
isActive: true,
|
||
emailVerified: true
|
||
});
|
||
|
||
// Assign organizational roles if provided
|
||
if (userData.organizationalRoleIds && userData.organizationalRoleIds.length > 0) {
|
||
await storage.setUserOrganizationalRoles(user.id, userData.organizationalRoleIds);
|
||
}
|
||
|
||
// Set custom fields if provided
|
||
if (userData.customFields && userData.customFields.length > 0) {
|
||
await storage.setUserCustomValues(user.id, userData.customFields, organizationId);
|
||
}
|
||
|
||
await addUserToDefaultGroup(user.id, organizationId);
|
||
|
||
notificationService.createDefaultSubscriptions(user.id, organizationId).catch(err =>
|
||
console.error('[Auth] Failed to create default notification subscriptions:', err)
|
||
);
|
||
|
||
return {
|
||
success: true,
|
||
user,
|
||
message: userData.password
|
||
? 'Пользователь создан с указанным паролем'
|
||
: (userData.sendInvite
|
||
? `Пользователь создан. Временный пароль: ${tempPassword}. Сообщите его новому пользователю.`
|
||
: 'Пользователь создан')
|
||
};
|
||
} catch (error) {
|
||
console.error('Create user error:', error);
|
||
// Ошибки валидации пароля из hashPassword отдаём клиенту как есть
|
||
if (error instanceof Error && error.message.includes('Пароль должен')) {
|
||
return { success: false, error: error.message };
|
||
}
|
||
return { success: false, error: 'Ошибка при создании пользователя' };
|
||
}
|
||
});
|
||
}
|
||
|
||
// Ротация refresh-токена внутри family: текущая сессия помечается заменённой,
|
||
// создаётся новая с новой парой токенов. expiresAt ограничен потолком family.
|
||
// В БД пишутся только sha256-хэши токенов (шаг 0.6).
|
||
private async rotateFamilySession(
|
||
session: UserSession,
|
||
user: UserWithOrganization
|
||
): Promise<{ tokens: TokenPair; remember: boolean }> {
|
||
const remember = session.remember ?? false;
|
||
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
|
||
const msLifetime = tokenLifetimeDays * 24 * 60 * 60 * 1000;
|
||
const familyCreatedAt = new Date(session.familyCreatedAt).getTime();
|
||
const maxExpiresAt = new Date(familyCreatedAt + msLifetime);
|
||
const proposedExpiresAt = new Date(Date.now() + msLifetime);
|
||
const expiresAt = proposedExpiresAt < maxExpiresAt ? proposedExpiresAt : maxExpiresAt;
|
||
|
||
const tokens = generateTokens({
|
||
userId: user.id,
|
||
organizationId: user.organizationId,
|
||
appRole: user.appRole
|
||
}, remember, session.familyId);
|
||
|
||
// Атомарная ротация: replaced + insert новой сессии в одной транзакции
|
||
const newSession = await storage.rotateUserSession(session.id, {
|
||
userId: user.id,
|
||
refreshTokenHash: hashToken(tokens.refreshToken),
|
||
familyId: session.familyId,
|
||
familyCreatedAt: session.familyCreatedAt,
|
||
parentRefreshTokenHash: session.refreshTokenHash,
|
||
expiresAt,
|
||
remember,
|
||
});
|
||
|
||
// Access-токен перевыпускаем с sid — для отзыва устройства в моменте
|
||
tokens.accessToken = signAccessToken({
|
||
userId: user.id,
|
||
organizationId: user.organizationId,
|
||
appRole: user.appRole,
|
||
}, newSession.id);
|
||
|
||
return { tokens, remember };
|
||
}
|
||
|
||
// Обработка повторного использования уже сменённого (rotated) refresh-токена.
|
||
// rotated — successor-сессия, найденная по parentRefreshTokenHash.
|
||
// В пределах RACE_TOLERANCE_MS это гонка вкладок/PWA: ротируем активную
|
||
// сессию family и выдаём новую пару (200). Если активной сессии нет —
|
||
// отказ БЕЗ стирания cookie и БЕЗ отзыва family. После окна — настоящий
|
||
// reuse-attack: отзыв всей family.
|
||
private async handleRotatedToken(rotated: UserSession): Promise<AuthResponse> {
|
||
if (rotated.isRevoked) {
|
||
console.warn(`[Auth] Refresh denied: rotated session ${rotated.id} is revoked (family ${rotated.familyId})`);
|
||
return { success: false, error: 'Недействительный или истекший токен' };
|
||
}
|
||
|
||
const rotatedAt = rotated.createdAt ? new Date(rotated.createdAt).getTime() : 0;
|
||
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
|
||
// В grace-окне: plain-токены в БД не хранятся (шаг 0.6), вернуть «тот же»
|
||
// refresh нельзя — вместо этого активная сессия ротируется и клиент
|
||
// получает новую пару.
|
||
const activeSession = await storage.getActiveSessionInFamily(rotated.familyId);
|
||
if (activeSession) {
|
||
const activeUser = await storage.getUserWithOrganization(activeSession.userId);
|
||
if (activeUser && activeUser.isActive) {
|
||
const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser);
|
||
return {
|
||
success: true,
|
||
user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any,
|
||
remember,
|
||
tokens,
|
||
};
|
||
}
|
||
}
|
||
// Successor есть, но активной сессии не нашлось — неоднозначная ситуация.
|
||
// Cookie не трогаем и family не отзываем: у клиента может быть валидная
|
||
// cookie winner-запроса, он повторит рефреш ею.
|
||
console.warn(`[Auth] Refresh in grace window but no active session in family ${rotated.familyId}`);
|
||
return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
|
||
}
|
||
|
||
// Reuse detected (за пределами grace-окна): revoke the whole family.
|
||
console.warn(`[Auth] Refresh token reuse detected for family ${rotated.familyId}, rotated session ${rotated.id}`);
|
||
await storage.revokeSessionFamily(rotated.familyId);
|
||
return { success: false, error: 'Недействительный или истекший токен' };
|
||
}
|
||
|
||
async refreshToken(refreshToken: string): Promise<AuthResponse> {
|
||
return withSuperAdmin(async () => {
|
||
try {
|
||
const session = await storage.getUserSessionByToken(refreshToken);
|
||
|
||
// Token not found as current — maybe it was already rotated (race or reuse).
|
||
if (!session) {
|
||
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
|
||
if (rotatedSession) {
|
||
return this.handleRotatedToken(rotatedSession);
|
||
}
|
||
// Токен вообще неизвестен — cookie не стираем (у клиента может оставаться
|
||
// живой access-cookie), family-ревока не для чего вызывать.
|
||
console.warn('[Auth] Refresh denied: token not found as current or rotated');
|
||
return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
|
||
}
|
||
|
||
if (session.isRevoked) {
|
||
console.warn(`[Auth] Refresh denied: session ${session.id} is revoked (family ${session.familyId})`);
|
||
return { success: false, error: 'Недействительный или истекший токен' };
|
||
}
|
||
if (session.isReplaced) {
|
||
// Гонка ротации: этот токен уже заменён, но его hash в БД сохранён —
|
||
// ищем successor-сессию и решаем по grace-окну.
|
||
const successor = await storage.getUserSessionByParentToken(refreshToken);
|
||
if (successor) {
|
||
return this.handleRotatedToken(successor);
|
||
}
|
||
// Successor не найден (например, запись не дочитана репликой/удалена) —
|
||
// 401 БЕЗ стирания cookie и БЕЗ revoke family.
|
||
console.warn(`[Auth] Refresh denied: session ${session.id} is replaced but no successor found (family ${session.familyId})`);
|
||
return { success: false, error: 'Недействительный или истекший токен', clearCookies: false };
|
||
}
|
||
if (session.expiresAt < new Date()) {
|
||
console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`);
|
||
return { success: false, error: 'Недействительный или истекший токен' };
|
||
}
|
||
|
||
const decoded = verifyRefreshToken(refreshToken);
|
||
if (decoded.userId !== session.userId) {
|
||
console.warn(`[Auth] Refresh denied: token userId ${decoded.userId} does not match session userId ${session.userId}`);
|
||
await storage.revokeSessionFamily(session.familyId);
|
||
return { success: false, error: 'Недействительный токен' };
|
||
}
|
||
|
||
const user = await storage.getUserWithOrganization(session.userId);
|
||
if (!user || !user.isActive) {
|
||
console.warn(`[Auth] Refresh denied: user ${session.userId} not found or inactive`);
|
||
return { success: false, error: 'Пользователь не найден или заблокирован' };
|
||
}
|
||
if (user.organization && !user.organization.isActive) {
|
||
console.warn(`[Auth] Refresh denied: organization ${user.organizationId} inactive`);
|
||
return { success: false, error: 'Доступ организации заблокирован' };
|
||
}
|
||
|
||
const { tokens, remember } = await this.rotateFamilySession(session, user);
|
||
|
||
const permissions = await storage.getAppRolePermissions(user.appRole);
|
||
return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, remember, tokens };
|
||
} catch (error) {
|
||
console.error('[Auth] Refresh token error:', error);
|
||
return { success: false, error: 'Ошибка при обновлении токена' };
|
||
}
|
||
});
|
||
}
|
||
|
||
async logout(refreshToken: string): Promise<void> {
|
||
try {
|
||
await withSuperAdmin(() => storage.revokeSession(refreshToken));
|
||
} catch (error) {
|
||
console.error('Logout error:', error);
|
||
}
|
||
}
|
||
|
||
async invalidateAllUserSessions(userId: number): Promise<void> {
|
||
try {
|
||
await withSuperAdmin(() => storage.revokeAllUserSessions(userId));
|
||
} catch (error) {
|
||
console.error('Invalidate all sessions error:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const authService = new AuthService();
|