fix idp on started

This commit is contained in:
lendry
2026-06-24 16:53:06 +03:00
parent d16eccb4c2
commit e60d55f6bd
15 changed files with 709 additions and 52 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, UseFilters } from '@nestjs/common';
import { BadRequestException, Controller, UseFilters } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
import { AdminService } from './admin.service';
@@ -20,6 +20,7 @@ import { FamilyService } from './family.service';
import { MediaService } from './media.service';
import { NotificationsService } from './notifications.service';
import { ChatService } from './chat.service';
import { MessagingService } from '../infra/messaging.service';
@Controller()
@UseFilters(new GrpcExceptionFilter())
@@ -41,7 +42,8 @@ export class AuthGrpcController {
private readonly family: FamilyService,
private readonly media: MediaService,
private readonly notifications: NotificationsService,
private readonly chat: ChatService
private readonly chat: ChatService,
private readonly messaging: MessagingService
) {}
@GrpcMethod('AuthService', 'Register')
@@ -437,6 +439,15 @@ export class AuthGrpcController {
return this.settings.deleteSocialProvider(command.providerName);
}
@GrpcMethod('SettingsService', 'TestMessagingDelivery')
async testMessagingDelivery(command: { channel: string; target: string }) {
const channel = command.channel === 'sms' ? 'sms' : 'email';
if (!command.target?.trim()) {
throw new BadRequestException('Укажите email или номер телефона для тестовой отправки');
}
return this.messaging.sendTestDelivery(channel, command.target.trim());
}
@GrpcMethod('ProfileService', 'GetProfile')
getProfile(command: { userId: string }) {
return this.profile.getProfile(command.userId);

View File

@@ -11,6 +11,7 @@ import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, vali
import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
import { AccessService } from './access.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
@@ -23,7 +24,8 @@ export class AuthService {
private readonly config: ConfigService,
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly ldapClient: LdapClientService
private readonly ldapClient: LdapClientService,
private readonly messaging: MessagingService
) {}
async register(command: RegisterCommand): Promise<PublicUser> {
@@ -131,19 +133,27 @@ export class AuthService {
async sendOtp(recipient: string, channel?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = 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));
const expiresAt = new Date(Date.now() + 5 * 60_000);
const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
await this.prisma.authCode.create({
data: {
userId: existingUser?.id,
target,
channel: target.includes('@') ? 'email' : 'sms',
channel: deliveryChannel,
purpose: 'passwordless-login',
codeHash: await bcrypt.hash(code, 10),
expiresAt
}
});
console.log('[OTP MOCK] Code for', target, 'is:', code);
await this.messaging.sendVerificationCode({
channel: deliveryChannel as 'email' | 'sms',
target,
code,
expiryMinutes
});
await this.redis.client.del(this.otpAttemptKey(target));
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
}
@@ -160,8 +170,20 @@ export class AuthService {
});
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 matches = await bcrypt.compare(command.code, authCode.codeHash);
if (!matches) throw new UnauthorizedException('Неверный код входа');
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);
@@ -183,6 +205,10 @@ export class AuthService {
return map[channel] ?? recipient;
}
private otpAttemptKey(target: string) {
return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
}
private maskTarget(value: string) {
if (value.includes('@')) {
const [name, domain] = value.split('@');

View File

@@ -3,12 +3,16 @@ import * as bcrypt from 'bcryptjs';
import { randomInt } from 'node:crypto';
import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { RedisService } from '../infra/redis.service';
@Injectable()
export class OtpService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: SettingsService
private readonly settings: SettingsService,
private readonly messaging: MessagingService,
private readonly redis: RedisService
) {}
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
@@ -25,7 +29,14 @@ export class OtpService {
expiresAt
}
});
console.log(`OTP ${command.channel} для ${command.target}: ${code}`);
const channel = command.channel === 'sms' ? 'sms' : 'email';
await this.messaging.sendVerificationCode({
channel,
target: command.target,
code,
expiryMinutes
});
await this.redis.client.del(this.attemptKey(command.target, command.purpose));
return { sent: true, expiresAt: expiresAt.toISOString() };
}
@@ -41,9 +52,25 @@ export class OtpService {
});
if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек');
const attemptKey = this.attemptKey(command.target, command.purpose);
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 matches = await bcrypt.compare(command.code, authCode.codeHash);
if (!matches) throw new UnauthorizedException('Неверный код подтверждения');
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() } });
return { verified: true };
}
private attemptKey(target: string, purpose: string) {
return `otp:attempts:${purpose}:${target.toLowerCase()}`;
}
}

View File

@@ -36,10 +36,25 @@ export const DEFAULT_SYSTEM_SETTINGS = [
},
{ key: 'LDAP_USERNAME_ATTR', value: 'sAMAccountName', description: 'Атрибут LDAP с логином пользователя' },
{ key: 'LDAP_EMAIL_ATTR', value: 'mail', description: 'Атрибут LDAP с почтой пользователя' },
{ key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' }
{ key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' },
{ key: 'EMAIL_ENABLED', value: 'false', description: 'Отправлять OTP-коды на email через настроенный SMTP' },
{ key: 'EMAIL_PROVIDER', value: 'smtp', description: 'Провайдер email: smtp или console (только лог)' },
{ key: 'EMAIL_SMTP_HOST', value: '', description: 'SMTP-сервер, например smtp.yandex.ru или smtp.gmail.com' },
{ key: 'EMAIL_SMTP_PORT', value: '587', description: 'Порт SMTP (587 — STARTTLS, 465 — SSL)' },
{ key: 'EMAIL_SMTP_SECURE', value: 'false', description: 'SSL/TLS при подключении (true для порта 465)' },
{ key: 'EMAIL_SMTP_USER', value: '', description: 'Логин SMTP (обычно совпадает с адресом отправителя)' },
{ key: 'EMAIL_SMTP_PASSWORD', value: '', description: 'Пароль или app-password SMTP' },
{ key: 'EMAIL_FROM_ADDRESS', value: '', description: 'Адрес отправителя, например noreply@example.com' },
{ key: 'EMAIL_FROM_NAME', value: '', description: 'Имя отправителя в письме (пусто = название проекта)' },
{ key: 'SMS_ENABLED', value: 'false', description: 'Отправлять OTP-коды по SMS через настроенного провайдера' },
{ key: 'SMS_PROVIDER', value: 'smsru', description: 'SMS-провайдер: smsru, smsc или console (только лог)' },
{ key: 'SMS_API_KEY', value: '', description: 'API-ключ sms.ru (api_id)' },
{ key: 'SMS_LOGIN', value: '', description: 'Логин smsc.ru' },
{ key: 'SMS_PASSWORD', value: '', description: 'Пароль smsc.ru' },
{ key: 'SMS_SENDER', value: '', description: 'Имя отправителя SMS (если поддерживается провайдером)' }
] as const;
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD']);
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD', 'EMAIL_SMTP_PASSWORD', 'SMS_API_KEY', 'SMS_PASSWORD']);
export const PUBLIC_SETTING_KEYS = [
'PROJECT_NAME',