Files
iistwin/server/services/auth.service.ts
Ильяс Султанов 06bfd2aa75
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex)
- migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся
- storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии
- auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена
- auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме
- tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset)
- .env.example, swagger, IMPLEMENTATION_LOG.md обновлены

Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
2026-09-08 11:35:32 +03:00

428 lines
18 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 { storage } from '../storage';
import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password';
import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, hashToken, 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;
}
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 не хранится.
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,
});
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);
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);
await storage.markSessionReplaced(session.id, tokens.refreshToken);
await storage.createUserSession({
userId: user.id,
refreshTokenHash: hashToken(tokens.refreshToken),
familyId: session.familyId,
familyCreatedAt: session.familyCreatedAt,
parentRefreshTokenHash: session.refreshTokenHash,
expiresAt,
remember,
});
return { tokens, remember };
}
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 && !rotatedSession.isRevoked) {
const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0;
// Within grace period: rotate the current active session of the same family.
// Plain-токены в БД не хранятся (шаг 0.6), поэтому вернуть «тот же» refresh
// нельзя — вместо этого активная сессия ротируется и клиент получает новую пару.
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
const activeSession = await storage.getActiveSessionInFamily(rotatedSession.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,
};
}
}
}
// Reuse detected: revoke the whole family.
console.warn(`[Auth] Refresh token reuse detected for family ${rotatedSession.familyId}, rotating session ${rotatedSession.id}`);
await storage.revokeSessionFamily(rotatedSession.familyId);
}
console.warn('[Auth] Refresh denied: token not found as current or rotated');
return { success: false, error: 'Недействительный или истекший токен' };
}
if (session.isRevoked) {
console.warn(`[Auth] Refresh denied: session ${session.id} is revoked (family ${session.familyId})`);
return { success: false, error: 'Недействительный или истекший токен' };
}
if (session.isReplaced) {
console.warn(`[Auth] Refresh denied: session ${session.id} is already replaced (family ${session.familyId})`);
return { success: false, error: 'Недействительный или истекший токен' };
}
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();