Files
IdP/apps/sso-core/src/domain/auth.service.ts
2026-06-29 22:51:25 +03:00

901 lines
34 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 { 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';
}
}