import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import { randomBytes, randomInt } from 'node:crypto'; import { DeviceType, Prisma, SessionStatus, User, UserStatus } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { RedisService } from '../infra/redis.service'; import { LdapClientService, LdapConnectionConfig } from '../infra/ldap-client.service'; import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, validateLdapConfig } from '../infra/ldap-config.util'; import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto'; import { AccessService } from './access.service'; import { SettingsService } from './settings.service'; import { MessagingService } from '../infra/messaging.service'; import { NotificationsService } from './notifications.service'; import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util'; import { resolveDeviceName } from '../infra/client-device.util'; import { TotpService } from './totp.service'; import { RbacService } from './rbac.service'; import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants'; import { assertHumanAccount, assertNotReservedUsername, HUMAN_USER_WHERE } from './system-account.util'; const FIRST_ADMIN_LOCK = 'locks:first-super-admin'; const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60; type DeviceCommand = Pick; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly redis: RedisService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly access: AccessService, private readonly settings: SettingsService, private readonly ldapClient: LdapClientService, private readonly messaging: MessagingService, private readonly notifications: NotificationsService, private readonly totp: TotpService, private readonly rbac: RbacService ) {} async register(command: RegisterCommand): Promise { if (!command.email && !command.phone) { throw new BadRequestException('Укажите почту или телефон'); } assertNotReservedUsername(command.username); const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null; const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); if (!lockAcquired) { throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); } try { const user = await this.prisma.$transaction( async (tx) => { const usersCount = await tx.user.count({ where: { deletedAt: null } }); return tx.user.create({ data: { email: command.email, phone: command.phone, passwordHash, displayName: command.displayName, username: command.username, isSuperAdmin: usersCount === 0 } }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); await this.rbac.ensureDefaultUserRole(user.id); return await this.toPublicUser(user); } catch (error) { if (this.isUniqueError(error)) { throw new BadRequestException('Пользователь с такими данными уже существует'); } throw error; } finally { await this.redis.releaseLock(FIRST_ADMIN_LOCK); } } async login(command: LoginCommand): Promise { const user = await this.prisma.user.findFirst({ where: { OR: [{ email: command.login }, { phone: command.login }, { username: command.login }], deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE }, include: { pinCode: true } }); if (!user || user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Неверный логин или пароль'); } assertHumanAccount(user, { asAuth: true }); if (!user.passwordHash) { throw new UnauthorizedException('Для этого аккаунта используйте вход по коду'); } const passwordMatches = await bcrypt.compare(command.password, user.passwordHash); if (!passwordMatches) { await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль'); throw new UnauthorizedException('Неверный логин или пароль'); } const device = await this.upsertDevice(user.id, command); return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true }); } async beginTotpLogin(command: DeviceCommand & { recipient: string }) { const user = await this.findUserByRecipient(command.recipient); if (!user || user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Пользователь не найден'); } assertHumanAccount(user, { asAuth: true }); if (!(await this.totp.isEnabledForUser(user.id))) { throw new BadRequestException('Для этого аккаунта не настроен аутентификатор'); } const device = await this.upsertDevice(user.id, command); const challenge = await this.issueTotpChallenge( user, command, { ...command, login: command.recipient, password: '' }, device.id ); return { totpChallengeToken: challenge.totpChallengeToken }; } async verifyTotpLogin(totpChallengeToken: string, code: string) { let payload: { sub: string; typ: string; jti?: string }; try { payload = await this.jwt.verifyAsync(totpChallengeToken, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), issuer: 'id.lendry.ru' }); } catch { throw new UnauthorizedException('Сессия входа истекла, повторите попытку'); } if (payload.typ !== 'totp_login_challenge' || !payload.jti) { throw new UnauthorizedException('Некорректный токен подтверждения'); } const challengeKey = this.totpLoginChallengeKey(payload.jti); const raw = await this.redis.client.get(challengeKey); if (!raw) { throw new UnauthorizedException('Сессия входа истекла, повторите попытку'); } const challenge = JSON.parse(raw) as DeviceCommand & { userId: string; deviceId: string; signIn: LoginCommand }; if (challenge.userId !== payload.sub) { throw new UnauthorizedException('Некорректный токен подтверждения'); } const attemptKey = this.totpLoginAttemptKey(payload.jti); const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5); const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0); if (attempts >= maxAttempts) { throw new UnauthorizedException('Превышено число попыток ввода кода аутентификатора'); } const isValid = await this.totp.verifyEnabledCode(payload.sub, code); if (!isValid) { await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', TOTP_LOGIN_CHALLENGE_TTL_SECONDS); throw new UnauthorizedException('Неверный код аутентификатора'); } await this.redis.client.del(challengeKey); await this.redis.client.del(attemptKey); const user = await this.prisma.user.findFirst({ where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE }, include: { pinCode: true } }); if (!user) { throw new UnauthorizedException('Пользователь не найден или заблокирован'); } assertHumanAccount(user, { asAuth: true }); const auth = await this.createAuthTokens(user, challenge.deviceId, challenge); await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId); return auth; } async identify(login: string) { const user = await this.findUserByRecipient(login); const methods: Array<{ kind: string; channel: string; masked: string }> = []; // Основные каналы (телефон/почта) — приоритетная доставка OTP по умолчанию. const otpChannels = user ? this.buildOtpChannels(user) : []; // Резервные каналы — отдельный список, чтобы пользователь мог явно // запросить код на резервный телефон/почту, но не получал его туда по умолчанию. const otpBackupChannels = user ? this.buildOtpBackupChannels(user) : []; if (user?.passwordHash) { methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' }); } return { exists: Boolean(user), hasPassword: Boolean(user?.passwordHash), isPinEnabled: Boolean(user?.pinCode?.isEnabled), isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false, otpChannels, otpBackupChannels, methods }; } async sendOtp(recipient: string, channel?: string, ipAddress?: string) { const existingUser = await this.findUserByRecipient(recipient); const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser)); const cooldownSeconds = await this.settings.getNumber('OTP_RESEND_COOLDOWN_SECONDS', 60); const resendKey = this.otpResendKey(target); const ttl = await this.redis.client.ttl(resendKey); if (ttl > 0) { throw new HttpException( { message: `Запросить новый код можно через ${this.formatOtpCooldown(ttl)}`, code: 'OTP_RESEND_COOLDOWN', resendAvailableAt: new Date(Date.now() + ttl * 1000).toISOString() }, HttpStatus.TOO_MANY_REQUESTS ); } await this.prisma.authCode.updateMany({ where: { target, purpose: 'passwordless-login', usedAt: null }, data: { usedAt: new Date() } }); const deliveryChannel = target.includes('@') ? 'email' : 'sms'; const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10); const code = String(randomInt(100000, 999999)); const expiresAt = new Date(Date.now() + expiryMinutes * 60_000); await this.prisma.authCode.create({ data: { userId: existingUser?.id, target, channel: deliveryChannel, purpose: 'passwordless-login', codeHash: await bcrypt.hash(code, 10), expiresAt } }); await this.messaging.sendVerificationCode({ channel: deliveryChannel as 'email' | 'sms', target, code, expiryMinutes }); if (existingUser) { await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт'); } await this.redis.client.del(this.otpAttemptKey(target)); await this.redis.client.set(resendKey, '1', 'EX', cooldownSeconds); const resendAvailableAt = new Date(Date.now() + cooldownSeconds * 1000).toISOString(); return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target), resendAvailableAt }; } private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) { const ipLabel = ipAddress?.trim() || 'неизвестного IP'; await this.notifications.create( userId, 'otp_code', action, `Попытка входа с ${ipLabel} — код для верификации: ${code}`, { code, ipAddress: ipAddress ?? null, action }, { skipPublish: false } ); } private async publishLoginSuccessNotification( userId: string, deviceId: string, ipAddress?: string | null, userAgent?: string | null ) { try { const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); const deviceLabel = device?.name ?? resolveDeviceName(undefined, userAgent); const ipLabel = ipAddress?.trim() || 'неизвестный IP'; await this.notifications.create( userId, 'login_success', 'Выполнен вход', `Выполнен вход с устройства «${deviceLabel}» и IP ${ipLabel}`, { deviceId, deviceName: deviceLabel, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null } ); } catch { // Уведомление о входе не должно блокировать авторизацию. } } async verifyOtp(command: LoginCommand & { recipient: string; code: string }) { const existingUser = await this.findUserByRecipient(command.recipient); const targetVariants = this.recipientLookupTargets(command.recipient); const authCode = await this.prisma.authCode.findFirst({ where: { purpose: 'passwordless-login', usedAt: null, expiresAt: { gt: new Date() }, OR: [ { target: { in: targetVariants } }, ...(existingUser ? [{ userId: existingUser.id }] : []) ] }, orderBy: { createdAt: 'desc' } }); if (!authCode) throw new UnauthorizedException('Код входа не найден или истек'); const attemptKey = this.otpAttemptKey(authCode.target); const maxAttempts = await this.settings.getNumber('OTP_MAX_ATTEMPTS', 5); const attempts = Number((await this.redis.client.get(attemptKey)) ?? 0); if (attempts >= maxAttempts) { throw new UnauthorizedException('Превышено число попыток ввода кода'); } const normalizedCode = command.code.replace(/\s/g, ''); const matches = await bcrypt.compare(normalizedCode, authCode.codeHash); if (!matches) { await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60); throw new UnauthorizedException('Неверный код входа'); } await this.redis.client.del(attemptKey); await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } }); const user = await this.findOrCreatePasswordlessUser(command.recipient); const device = await this.upsertDevice(user.id, command); const auth = await this.finalizeAuthentication( user, command, { ...command, login: command.recipient, password: '' }, device.id, { skipTotp: true } ); return { requiresPassword: false, auth }; } private buildOtpChannels(user: { email: string | null; phone: string | null }) { const channels: Array<{ channel: string; masked: string }> = []; if (user.email) { channels.push({ channel: 'email', masked: this.maskTarget(user.email) }); } if (user.phone) { channels.push({ channel: 'phone', masked: this.maskTarget(user.phone) }); } return channels; } private buildOtpBackupChannels(user: { backupEmail: string | null; backupPhone: string | null }) { const channels: Array<{ channel: string; masked: string }> = []; if (user.backupEmail) { channels.push({ channel: 'backupEmail', masked: this.maskTarget(user.backupEmail) }); } if (user.backupPhone) { channels.push({ channel: 'backupPhone', masked: this.maskTarget(user.backupPhone) }); } return channels; } private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) { if (!channel || channel === 'primary' || !user) return recipient; const map: Record = { email: user.email, phone: user.phone, backupEmail: user.backupEmail, backupPhone: user.backupPhone }; return map[channel] ?? recipient; } private otpAttemptKey(target: string) { return `otp:attempts:passwordless-login:${target.toLowerCase()}`; } private otpResendKey(target: string) { return `otp:resend:passwordless-login:${target.toLowerCase()}`; } private formatOtpCooldown(seconds: number) { const minutes = Math.floor(seconds / 60); const rest = seconds % 60; if (minutes > 0 && rest > 0) { return `${minutes}:${String(rest).padStart(2, '0')}`; } if (minutes > 0) { return `${minutes} мин`; } return `${rest} сек`; } private maskTarget(value: string) { if (value.includes('@')) { const [name, domain] = value.split('@'); const visible = name.slice(0, 1); return `${visible}${'*'.repeat(Math.max(name.length - 1, 1))}@${domain}`; } const tail = value.slice(-2); const head = value.slice(0, value.startsWith('+') ? 2 : 1); return `${head}${'*'.repeat(Math.max(value.length - head.length - 2, 2))}${tail}`; } async loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) { const user = command.tempAuthToken ? await this.findUserByTempPasswordToken(command.tempAuthToken) : await this.findUserByRecipient(command.login); if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован'); assertHumanAccount(user, { asAuth: true }); const matches = await bcrypt.compare(command.password, user.passwordHash); if (!matches) { await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль'); throw new UnauthorizedException('Неверный пароль'); } const device = await this.upsertDevice(user.id, command); return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true }); } async createQrApprovedLogin(userId: string, command: Pick) { const user = await this.prisma.user.findFirst({ where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE }, include: { pinCode: true } }); if (!user) { throw new UnauthorizedException('Пользователь не найден или заблокирован'); } assertHumanAccount(user, { asAuth: true }); const device = await this.upsertDevice(user.id, { fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent }); return this.finalizeAuthentication( user, command, { fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent, login: 'qr-login', password: '' }, device.id, { skipTotp: true } ); } async loginWithLdap(command: { username: string; password: string; fingerprint: string; deviceName?: string; deviceType?: string; ipAddress?: string; userAgent?: string; }): Promise { const enabled = await this.settings.getBoolean('LDAP_ENABLED', false); if (!enabled) { throw new UnauthorizedException('LDAP-аутентификация отключена'); } const ldapConfig = await this.getLdapConfig(); validateLdapConfig(ldapConfig); let ldapResult; try { ldapResult = await this.ldapClient.authenticate(ldapConfig, command.username, command.password); } catch (error) { throw new UnauthorizedException(error instanceof Error ? error.message : 'Неверный логин или пароль LDAP'); } const user = await this.findOrCreateLdapUser(ldapResult); const device = await this.upsertDevice(user.id, command); return this.finalizeAuthentication( user, command, { login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent }, device.id, { skipTotp: true } ); } private async getLdapConfig(): Promise { const useLdaps = await this.settings.getBoolean('LDAP_USE_LDAPS', false); const rawPort = await this.settings.getNumber('LDAP_PORT', useLdaps ? 636 : 389); const domain = await this.settings.getValue('LDAP_DOMAIN', ''); const bindUsername = await this.settings.getValue('LDAP_BIND_USERNAME', ''); const bindDnRaw = await this.settings.getValue('LDAP_BIND_DN', ''); const bindDn = resolveLdapBindIdentity(bindUsername, bindDnRaw, domain); return { host: await this.settings.getValue('LDAP_HOST', ''), port: resolveLdapPort(rawPort, useLdaps), useLdaps, skipTlsVerify: await this.settings.getBoolean('LDAP_SKIP_TLS_VERIFY', false), domain, baseDn: await this.settings.getValue('LDAP_BASE_DN', ''), bindDn, bindPassword: await this.settings.getValue('LDAP_BIND_PASSWORD', ''), userFilter: normalizeLdapUserFilter( await this.settings.getValue( 'LDAP_USER_FILTER', '(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))' ) ), usernameAttr: await this.settings.getValue('LDAP_USERNAME_ATTR', 'sAMAccountName'), emailAttr: await this.settings.getValue('LDAP_EMAIL_ATTR', 'mail'), displayNameAttr: await this.settings.getValue('LDAP_DISPLAY_NAME_ATTR', 'displayName') }; } private async findOrCreateLdapUser(ldapResult: { dn: string; username: string; email?: string; displayName: string }) { const linked = await this.prisma.linkedAccount.findUnique({ where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } }, include: { user: { include: { pinCode: true } } } }); if (linked?.user && !linked.user.deletedAt && linked.user.status === UserStatus.ACTIVE) { return linked.user; } if (ldapResult.email) { const byEmail = await this.prisma.user.findFirst({ where: { email: ldapResult.email, deletedAt: null, status: UserStatus.ACTIVE }, include: { pinCode: true } }); if (byEmail) { await this.prisma.linkedAccount.upsert({ where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } }, create: { userId: byEmail.id, providerName: 'ldap', providerId: ldapResult.dn, email: ldapResult.email, displayName: ldapResult.displayName }, update: { email: ldapResult.email, displayName: ldapResult.displayName } }); return byEmail; } } const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); if (!lockAcquired) { throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); } try { const user = await this.prisma.$transaction( async (tx) => { const usersCount = await tx.user.count({ where: { deletedAt: null } }); return tx.user.create({ data: { email: ldapResult.email ?? undefined, username: ldapResult.username, displayName: ldapResult.displayName, passwordHash: null, isSuperAdmin: usersCount === 0, linkedAccounts: { create: { providerName: 'ldap', providerId: ldapResult.dn, email: ldapResult.email, displayName: ldapResult.displayName } } }, include: { pinCode: true } }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); await this.rbac.ensureDefaultUserRole(user.id); return user; } finally { await this.redis.releaseLock(FIRST_ADMIN_LOCK); } } private async finalizeAuthentication( user: User & { pinCode?: { isEnabled: boolean } | null }, deviceCommand: DeviceCommand, signInCommand: LoginCommand, deviceId: string, options?: { skipTotp?: boolean } ): Promise { assertHumanAccount(user, { asAuth: true }); if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) { return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId); } const auth = await this.createAuthTokens(user, deviceId, deviceCommand); await this.writeSignInEvent(user.id, true, signInCommand, deviceId); return auth; } private async issueTotpChallenge( user: User & { pinCode?: { isEnabled: boolean } | null }, deviceCommand: DeviceCommand, signInCommand: LoginCommand, deviceId: string ): Promise { const challengeId = randomBytes(16).toString('base64url'); const totpChallengeToken = await this.jwt.signAsync( { sub: user.id, typ: 'totp_login_challenge', jti: challengeId }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' } ); await this.redis.client.set( this.totpLoginChallengeKey(challengeId), JSON.stringify({ userId: user.id, deviceId, signIn: signInCommand, ...deviceCommand }), 'EX', TOTP_LOGIN_CHALLENGE_TTL_SECONDS ); return { requiresTotp: true, totpChallengeToken, accessToken: '', refreshToken: '', expiresAt: new Date().toISOString(), pinVerified: false, sessionId: '', user: await this.toPublicUser(user) }; } private totpLoginChallengeKey(challengeId: string) { return `totp:login:${challengeId}`; } private totpLoginAttemptKey(challengeId: string) { return `totp:login:attempts:${challengeId}`; } private async upsertDevice(userId: string, command: Pick) { const resolvedName = resolveDeviceName(command.deviceName, command.userAgent); return this.prisma.device.upsert({ where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } }, create: { userId, fingerprint: command.fingerprint, name: resolvedName, type: this.toDeviceType(command.deviceType), lastIp: command.ipAddress }, update: { lastSeenAt: new Date(), lastIp: command.ipAddress, name: resolvedName, type: this.toDeviceType(command.deviceType) } }); } private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick): Promise { assertHumanAccount(user, { asAuth: true }); const pinVerified = !user.pinCode?.isEnabled; const refreshToken = randomBytes(48).toString('base64url'); const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); const session = await this.prisma.session.create({ data: { userId: user.id, deviceId, refreshHash: await bcrypt.hash(refreshToken, 12), pinVerified, status: pinVerified ? SessionStatus.ACTIVE : SessionStatus.LOCKED, ipAddress: command.ipAddress, userAgent: command.userAgent, expiresAt } }); await this.notifications.dismissOtpCodeNotifications(user.id); await this.publishLoginSuccessNotification(user.id, deviceId, command.ipAddress, command.userAgent); return { accessToken: await this.issueAccessToken(user, session.id, pinVerified), refreshToken, expiresAt: expiresAt.toISOString(), pinVerified, user: await this.toPublicUser(user), sessionId: session.id }; } private async issueTempPasswordToken(userId: string) { return this.jwt.signAsync( { sub: userId, typ: 'password_challenge' }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' } ); } private normalizeOtpTarget(target: string) { if (target.includes('@')) { return target.trim().toLowerCase(); } return canonicalPhoneE164(target); } private recipientLookupTargets(recipient: string) { if (recipient.includes('@')) { return [recipient.trim().toLowerCase()]; } return phoneLookupVariants(recipient); } private async findUserByRecipient(recipient: string) { if (recipient.includes('@')) { return this.prisma.user.findFirst({ where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE }, include: { pinCode: true } }); } const variants = phoneLookupVariants(recipient); return this.prisma.user.findFirst({ where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE }, include: { pinCode: true } }); } private async findUserByTempPasswordToken(tempAuthToken: string) { const payload = await this.jwt.verifyAsync<{ sub: string; typ: string }>(tempAuthToken, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), issuer: 'id.lendry.ru' }); if (payload.typ !== 'password_challenge') throw new UnauthorizedException('Временный токен недействителен'); const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { pinCode: true } }); if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Пользователь не найден или заблокирован'); } assertHumanAccount(user, { asAuth: true }); return user; } private async findOrCreatePasswordlessUser(recipient: string) { const existingUser = await this.findUserByRecipient(recipient); if (existingUser) return existingUser; const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); try { const user = await this.prisma.$transaction( async (tx) => { const usersCount = await tx.user.count({ where: { deletedAt: null } }); return tx.user.create({ data: { email: recipient.includes('@') ? recipient : undefined, phone: recipient.includes('@') ? undefined : recipient, passwordHash: null, displayName: recipient, isSuperAdmin: usersCount === 0 } }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); await this.rbac.ensureDefaultUserRole(user.id); return user; } finally { await this.redis.releaseLock(FIRST_ADMIN_LOCK); } } async getMe(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Пользователь не найден или заблокирован'); } assertHumanAccount(user, { asAuth: true }); return this.toPublicUser(user); } async toPublicUser(user: Pick): Promise { const access = await this.access.getUserAccess(user.id, user.isSuperAdmin); return { id: user.id, email: user.email, phone: user.phone, backupEmail: user.backupEmail, backupPhone: user.backupPhone, displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl), isSuperAdmin: user.isSuperAdmin, isVerified: user.isVerified, verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined, status: user.status, hasPassword: Boolean(user.passwordHash), roles: access.roles, permissions: access.permissions, canAccessAdmin: access.canAccessAdmin, canManageRoles: access.canManageRoles, canViewOAuth: access.canViewOAuth, canManageOAuth: access.canManageOAuth, canManageAllOAuth: access.canManageAllOAuth, canManageUsers: access.canManageUsers, canManageAllUsers: access.canManageAllUsers, canManageSettings: access.canManageSettings, canViewUsers: access.canViewUsers, canViewUserDocuments: access.canViewUserDocuments, canVerifyUsers: access.canVerifyUsers, canManageBots: access.canManageBots }; } async issueAccessToken(user: Pick, sessionId: string, pinVerified: boolean) { assertHumanAccount(user, { asAuth: true }); return this.jwt.signAsync( { sub: user.id, sessionId, pinVerified, isSuperAdmin: user.isSuperAdmin }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' } ); } private async writeSignInEvent(userId: string, success: boolean, command: LoginCommand, deviceId?: string, reason?: string) { await this.prisma.signInEvent.create({ data: { userId, deviceId, success, reason, ipAddress: command.ipAddress, userAgent: command.userAgent } }); } private toDeviceType(value?: string): DeviceType { if (!value) return DeviceType.WEB; const normalized = value.toUpperCase(); return Object.values(DeviceType).includes(normalized as DeviceType) ? (normalized as DeviceType) : DeviceType.OTHER; } private isUniqueError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; } }