fix idp on started
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"amqplib": "^0.10.9",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"nodemailer": "^7.0.6",
|
||||
"pg": "^8.22.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"grpc-tools": "^1.13.1",
|
||||
"prisma": "^7.1.0",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('@');
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
213
apps/sso-core/src/infra/messaging.service.ts
Normal file
213
apps/sso-core/src/infra/messaging.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user