more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -12,8 +12,14 @@ 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 { TotpService } from './totp.service';
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 {
@@ -25,7 +31,9 @@ export class AuthService {
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly ldapClient: LdapClientService,
private readonly messaging: MessagingService
private readonly messaging: MessagingService,
private readonly notifications: NotificationsService,
private readonly totp: TotpService
) {}
async register(command: RegisterCommand): Promise<PublicUser> {
@@ -95,28 +103,99 @@ export class AuthService {
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
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('Пользователь не найден');
}
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 },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
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 }> = [];
const otpChannels = user ? this.buildOtpChannels(user) : [];
if (user?.passwordHash) {
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
}
if (user) {
const channels: Array<{ channel: string; value: string | null }> = [
{ channel: 'email', value: user.email },
{ channel: 'phone', value: user.phone },
const backupChannels: Array<{ channel: string; value: string | null }> = [
{ channel: 'backupEmail', value: user.backupEmail },
{ channel: 'backupPhone', value: user.backupPhone }
];
for (const item of channels) {
if (item.value && item.value !== login) {
for (const item of backupChannels) {
if (item.value) {
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
}
}
@@ -126,13 +205,15 @@ export class AuthService {
exists: Boolean(user),
hasPassword: Boolean(user?.passwordHash),
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
otpChannels,
methods
};
}
async sendOtp(recipient: string, channel?: string) {
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = this.resolveOtpTarget(recipient, channel, existingUser);
const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(randomInt(100000, 999999));
@@ -153,18 +234,37 @@ export class AuthService {
code,
expiryMinutes
});
if (existingUser) {
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
}
await this.redis.client.del(this.otpAttemptKey(target));
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
}
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 }
);
}
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: command.recipient }, ...(existingUser ? [{ userId: existingUser.id }] : [])]
OR: [
{ target: { in: targetVariants } },
...(existingUser ? [{ userId: existingUser.id }] : [])
]
},
orderBy: { createdAt: 'desc' }
});
@@ -178,7 +278,8 @@ export class AuthService {
throw new UnauthorizedException('Превышено число попыток ввода кода');
}
const matches = await bcrypt.compare(command.code, authCode.codeHash);
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('Неверный код входа');
@@ -189,9 +290,28 @@ export class AuthService {
const user = await this.findOrCreatePasswordlessUser(command.recipient);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id);
return { requiresPassword: false, tempAuthToken: undefined, auth };
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 resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
@@ -231,9 +351,38 @@ export class AuthService {
throw new UnauthorizedException('Неверный пароль');
}
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
return auth;
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 },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
const device = await this.upsertDevice(user.id, {
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress
});
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: {
@@ -261,14 +410,21 @@ export class AuthService {
const user = await this.findOrCreateLdapUser(ldapResult);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(
user.id,
true,
{ login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent },
device.id
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 }
);
return auth;
}
private async getLdapConfig(): Promise<LdapConnectionConfig> {
@@ -364,6 +520,66 @@ export class AuthService {
}
}
private async finalizeAuthentication(
user: User & { pinCode?: { isEnabled: boolean } | null },
deviceCommand: DeviceCommand,
signInCommand: LoginCommand,
deviceId: string,
options?: { skipTotp?: boolean }
): Promise<AuthTokens> {
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'>) {
return this.prisma.device.upsert({
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
@@ -400,6 +616,8 @@ export class AuthService {
}
});
await this.notifications.dismissOtpCodeNotifications(user.id);
return {
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
refreshToken,
@@ -417,11 +635,31 @@ export class AuthService {
);
}
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 },
include: { pinCode: true }
});
}
const variants = phoneLookupVariants(recipient);
return this.prisma.user.findFirst({
where: recipient.includes('@')
? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
: { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}