Files
iistwin/server/services/auth.service.ts
Ильяс Султанов 8d11ad3b33 fix(auth): список сессий без мусора ротаций — фильтр isReplaced, перенос UA/IP, чистка старых
- listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял
  в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи);
- rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии —
  rotated-строки больше не «Неизвестное устройство»;
- новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет
  replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие.
2026-09-22 09:01:59 +03:00

485 lines
22 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 { 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 новой сессии в одной транзакции.
// userAgent/ipAddress переносим из исходной сессии — иначе rotated-строки
// отображались бы в списке устройств как «Неизвестное устройство».
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,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
});
// 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();