901 lines
34 KiB
TypeScript
901 lines
34 KiB
TypeScript
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<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;
|
||
|
||
@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<PublicUser> {
|
||
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<AuthTokens> {
|
||
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<string>('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<string, string | null> = {
|
||
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<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
|
||
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<AuthTokens> {
|
||
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<LdapConnectionConfig> {
|
||
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<AuthTokens> {
|
||
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<AuthTokens> {
|
||
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<string>('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<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
|
||
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<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
|
||
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<string>('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<string>('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<PublicUser> {
|
||
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<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'isVerified' | 'verificationIcon' | 'status' | 'passwordHash' | 'isSystemAccount' | 'linkedBotId'>): Promise<PublicUser> {
|
||
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<User, 'id' | 'isSuperAdmin' | 'isSystemAccount' | 'linkedBotId'>, sessionId: string, pinVerified: boolean) {
|
||
assertHumanAccount(user, { asAuth: true });
|
||
|
||
return this.jwt.signAsync(
|
||
{
|
||
sub: user.id,
|
||
sessionId,
|
||
pinVerified,
|
||
isSuperAdmin: user.isSuperAdmin
|
||
},
|
||
{
|
||
secret: this.config.getOrThrow<string>('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';
|
||
}
|
||
}
|