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

@@ -29,6 +29,7 @@ import { ChatService } from './domain/chat.service';
import { NotificationPublisherService } from './infra/notification-publisher.service';
import { MinioService } from './infra/minio.service';
import { LdapClientService } from './infra/ldap-client.service';
import { MessagingService } from './infra/messaging.service';
@Module({
imports: [
@@ -62,7 +63,8 @@ import { LdapClientService } from './infra/ldap-client.service';
ChatService,
NotificationPublisherService,
MinioService,
LdapClientService
LdapClientService,
MessagingService
]
})
export class AppModule {}

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',

View File

@@ -0,0 +1,213 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import nodemailer from 'nodemailer';
import { SettingsService } from '../domain/settings.service';
type MessagingChannel = 'email' | 'sms';
interface VerificationPayload {
channel: MessagingChannel;
target: string;
code: string;
expiryMinutes: number;
}
@Injectable()
export class MessagingService {
private readonly logger = new Logger(MessagingService.name);
constructor(private readonly settings: SettingsService) {}
async sendVerificationCode(payload: VerificationPayload): Promise<void> {
if (payload.channel === 'email') {
await this.sendEmailCode(payload);
return;
}
await this.sendSmsCode(payload);
}
async sendTestDelivery(channel: MessagingChannel, target: string) {
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(Math.floor(100000 + Math.random() * 900000));
await this.sendVerificationCode({ channel, target, code, expiryMinutes });
return {
sent: true,
channel,
target,
message: `Тестовое сообщение отправлено на ${this.maskTarget(target, channel)}`
};
}
private async sendEmailCode(payload: VerificationPayload) {
const enabled = await this.settings.getBoolean('EMAIL_ENABLED', false);
const provider = (await this.settings.getValue('EMAIL_PROVIDER', 'smtp')).trim().toLowerCase();
if (!enabled || provider === 'console') {
this.logConsoleFallback('email', payload);
return;
}
if (provider !== 'smtp') {
throw new BadRequestException(`Неизвестный email-провайдер: ${provider}`);
}
const host = (await this.settings.getValue('EMAIL_SMTP_HOST', '')).trim();
const port = await this.settings.getNumber('EMAIL_SMTP_PORT', 587);
const secure = await this.settings.getBoolean('EMAIL_SMTP_SECURE', false);
const user = (await this.settings.getValue('EMAIL_SMTP_USER', '')).trim();
const password = await this.settings.getValue('EMAIL_SMTP_PASSWORD', '');
const fromAddress = (await this.settings.getValue('EMAIL_FROM_ADDRESS', '')).trim();
const fromName = (await this.settings.getValue('EMAIL_FROM_NAME', '')).trim();
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
if (!host || !fromAddress) {
throw new BadRequestException('Email не настроен: укажите SMTP-хост и адрес отправителя в админке');
}
const from = fromName ? `"${fromName}" <${fromAddress}>` : fromAddress;
const subject = `${projectName}: код подтверждения`;
const text = this.buildCodeText(projectName, payload.code, payload.expiryMinutes);
const html = this.buildCodeHtml(projectName, payload.code, payload.expiryMinutes);
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: user ? { user, pass: password } : undefined,
tls: secure ? undefined : { rejectUnauthorized: true }
});
try {
await transporter.sendMail({
from,
to: payload.target,
subject,
text,
html
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Неизвестная ошибка SMTP';
this.logger.error(`SMTP ошибка для ${payload.target}: ${message}`);
throw new BadRequestException(`Не удалось отправить письмо: ${message}`);
}
}
private async sendSmsCode(payload: VerificationPayload) {
const enabled = await this.settings.getBoolean('SMS_ENABLED', false);
const provider = (await this.settings.getValue('SMS_PROVIDER', 'smsru')).trim().toLowerCase();
if (!enabled || provider === 'console') {
this.logConsoleFallback('sms', payload);
return;
}
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
const phone = this.normalizePhone(payload.target);
const text = `${projectName}: код ${payload.code}. Действует ${payload.expiryMinutes} мин.`;
if (provider === 'smsru') {
await this.sendViaSmsRu(phone, text);
return;
}
if (provider === 'smsc') {
await this.sendViaSmsc(phone, text);
return;
}
throw new BadRequestException(`Неизвестный SMS-провайдер: ${provider}`);
}
private async sendViaSmsRu(phone: string, text: string) {
const apiId = (await this.settings.getValue('SMS_API_KEY', '')).trim();
const sender = (await this.settings.getValue('SMS_SENDER', '')).trim();
if (!apiId) {
throw new BadRequestException('SMS не настроен: укажите API-ключ sms.ru в админке');
}
const params = new URLSearchParams({
api_id: apiId,
to: phone,
msg: text,
json: '1'
});
if (sender) params.set('from', sender);
const response = await fetch(`https://sms.ru/sms/send?${params.toString()}`);
const body = (await response.json()) as { status?: string; status_code?: number; status_text?: string };
if (!response.ok || body.status !== 'OK') {
const reason = body.status_text ?? `HTTP ${response.status}`;
throw new BadRequestException(`sms.ru: ${reason}`);
}
}
private async sendViaSmsc(phone: string, text: string) {
const login = (await this.settings.getValue('SMS_LOGIN', '')).trim();
const password = (await this.settings.getValue('SMS_PASSWORD', '')).trim();
const sender = (await this.settings.getValue('SMS_SENDER', '')).trim();
if (!login || !password) {
throw new BadRequestException('SMS не настроен: укажите логин и пароль smsc.ru в админке');
}
const params = new URLSearchParams({
login,
psw: password,
phones: phone,
mes: text,
fmt: '3',
charset: 'utf-8'
});
if (sender) params.set('sender', sender);
const response = await fetch(`https://smsc.ru/sys/send.php?${params.toString()}`);
const body = (await response.json()) as { error?: string; error_code?: number; id?: number };
if (!response.ok || body.error) {
throw new BadRequestException(`smsc.ru: ${body.error ?? `HTTP ${response.status}`}`);
}
}
private normalizePhone(value: string) {
const digits = value.replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('8')) return `7${digits.slice(1)}`;
if (digits.length === 10) return `7${digits}`;
return digits;
}
private buildCodeText(projectName: string, code: string, expiryMinutes: number) {
return [
`Ваш код подтверждения ${projectName}: ${code}`,
'',
`Код действителен ${expiryMinutes} мин.`,
'Если вы не запрашивали код — проигнорируйте это сообщение.'
].join('\n');
}
private buildCodeHtml(projectName: string, code: string, expiryMinutes: number) {
return `
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#101828">
<p>Ваш код подтверждения <strong>${projectName}</strong>:</p>
<p style="font-size:28px;font-weight:700;letter-spacing:4px">${code}</p>
<p>Код действителен ${expiryMinutes} мин.</p>
<p style="color:#667085;font-size:13px">Если вы не запрашивали код — проигнорируйте это письмо.</p>
</div>
`.trim();
}
private logConsoleFallback(channel: MessagingChannel, payload: VerificationPayload) {
this.logger.warn(
`[${channel.toUpperCase()}] Код для ${payload.target}: ${payload.code} (провайдер отключён — включите EMAIL_ENABLED/SMS_ENABLED в админке)`
);
}
private maskTarget(target: string, channel: MessagingChannel) {
if (channel === 'email' && target.includes('@')) {
const [name, domain] = target.split('@');
return `${name.slice(0, 1)}***@${domain}`;
}
const digits = target.replace(/\D/g, '');
return `***${digits.slice(-4)}`;
}
}