From e60d55f6bd38157ff889685ee13bed1ac4aec726 Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 24 Jun 2026 16:53:06 +0300 Subject: [PATCH] fix idp on started --- .../src/controllers/settings.controller.ts | 14 +- apps/api-gateway/src/dto/settings.dto.ts | 13 +- apps/frontend/app/admin/settings/page.tsx | 27 ++- .../id/messaging-settings-section.tsx | 212 +++++++++++++++++ apps/frontend/lib/system-settings-catalog.ts | 55 ++++- apps/sso-core/package.json | 2 + apps/sso-core/src/app.module.ts | 4 +- .../src/domain/auth-grpc.controller.ts | 15 +- apps/sso-core/src/domain/auth.service.ts | 36 ++- apps/sso-core/src/domain/otp.service.ts | 33 ++- .../src/domain/system-settings.seed.ts | 19 +- apps/sso-core/src/infra/messaging.service.ts | 213 ++++++++++++++++++ install.sh | 84 ++++--- package-lock.json | 21 ++ shared/proto/security.proto | 13 ++ 15 files changed, 709 insertions(+), 52 deletions(-) create mode 100644 apps/frontend/components/id/messaging-settings-section.tsx create mode 100644 apps/sso-core/src/infra/messaging.service.ts diff --git a/apps/api-gateway/src/controllers/settings.controller.ts b/apps/api-gateway/src/controllers/settings.controller.ts index e230abd..f313c80 100644 --- a/apps/api-gateway/src/controllers/settings.controller.ts +++ b/apps/api-gateway/src/controllers/settings.controller.ts @@ -1,9 +1,9 @@ -import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; -import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto'; +import { ConnectLinkedAccountDto, TestMessagingDeliveryDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto'; import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard'; const settingsWritePipe = new ValidationPipe({ @@ -97,6 +97,16 @@ export class SettingsController { return this.core.settings.DeleteSocialProvider({ providerName }); } + @Post('messaging/test') + @UsePipes(settingsWritePipe) + @ApiOperation({ summary: 'Тест email/SMS', description: 'Отправляет тестовый OTP-код через настроенного провайдера.' }) + @ApiBody({ type: TestMessagingDeliveryDto }) + @ApiResponse({ status: 200, description: 'Тестовое сообщение отправлено' }) + testMessaging(@Body() dto: TestMessagingDeliveryDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.TestMessagingDelivery({ channel: dto.channel, target: dto.target }); + } + @Get('linked-accounts/users/:userId') @ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) diff --git a/apps/api-gateway/src/dto/settings.dto.ts b/apps/api-gateway/src/dto/settings.dto.ts index 35e5832..40d3fe9 100644 --- a/apps/api-gateway/src/dto/settings.dto.ts +++ b/apps/api-gateway/src/dto/settings.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class UpsertSettingDto { @ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' }) @@ -43,6 +43,17 @@ export class UpsertSocialProviderDto { isEnabled!: boolean; } +export class TestMessagingDeliveryDto { + @ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] }) + @IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' }) + channel!: 'email' | 'sms'; + + @ApiProperty({ description: 'Email или номер телефона получателя', example: 'user@example.com' }) + @IsString({ message: 'Получатель должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите email или телефон' }) + target!: string; +} + export class ConnectLinkedAccountDto { @ApiProperty({ description: 'Название провайдера', example: 'google' }) @IsString({ message: 'Название провайдера должно быть строкой' }) diff --git a/apps/frontend/app/admin/settings/page.tsx b/apps/frontend/app/admin/settings/page.tsx index dfead21..312b219 100644 --- a/apps/frontend/app/admin/settings/page.tsx +++ b/apps/frontend/app/admin/settings/page.tsx @@ -9,7 +9,8 @@ import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api'; -import { getSettingMeta, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog'; +import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog'; +import { MessagingSettingsSection } from '@/components/id/messaging-settings-section'; function parseBoolean(value: string) { return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); @@ -41,6 +42,7 @@ export default function AdminSettingsPage() { const groupedSettings = useMemo(() => { const groups = new Map(); for (const setting of settings) { + if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue; const meta = getSettingMeta(setting.key); const group = meta?.group ?? 'other'; const list = groups.get(group) ?? []; @@ -126,7 +128,7 @@ export default function AdminSettingsPage() {

{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}

- {groupSettings.map((setting) => { + {groupSettings.filter((setting) => isSettingVisible(setting, settings)).map((setting) => { const meta = getSettingMeta(setting.key); const label = meta?.label ?? setting.key; const type = meta?.type ?? 'text'; @@ -153,6 +155,18 @@ export default function AdminSettingsPage() { > {parseBoolean(setting.value) ? : } + ) : type === 'select' ? ( + ) : (
))} + +

OAuth Social Providers

diff --git a/apps/frontend/components/id/messaging-settings-section.tsx b/apps/frontend/components/id/messaging-settings-section.tsx new file mode 100644 index 0000000..52d058a --- /dev/null +++ b/apps/frontend/components/id/messaging-settings-section.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { Loader2, Mail, MessageSquare, Save, Send, ToggleLeft, ToggleRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { PhoneInput, phoneCountries } from '@/components/ui/phone-input'; +import { apiFetch, buildSystemSettingPayload, SystemSetting } from '@/lib/api'; +import { getSettingMeta, isSettingVisible, MESSAGING_SETTING_KEYS } from '@/lib/system-settings-catalog'; + +function parseBoolean(value: string) { + return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); +} + +interface MessagingSettingsSectionProps { + settings: SystemSetting[]; + token: string | null; + savingKey: string | null; + onUpdateValue: (key: string, value: string) => void; + onSave: (setting: SystemSetting) => Promise; + showToast: (message: string) => void; +} + +function getSettingValue(settings: SystemSetting[], key: string) { + return settings.find((item) => item.key === key)?.value ?? ''; +} + +function shouldShowMessagingField(setting: SystemSetting, settings: SystemSetting[]) { + if (!isSettingVisible(setting, settings)) return false; + + const provider = getSettingValue(settings, 'SMS_PROVIDER'); + if (setting.key === 'SMS_API_KEY') return provider === 'smsru'; + if (setting.key === 'SMS_LOGIN' || setting.key === 'SMS_PASSWORD') return provider === 'smsc'; + + const emailProvider = getSettingValue(settings, 'EMAIL_PROVIDER'); + if (setting.key.startsWith('EMAIL_SMTP_') || setting.key.startsWith('EMAIL_FROM_')) { + return parseBoolean(getSettingValue(settings, 'EMAIL_ENABLED')) && emailProvider === 'smtp'; + } + if (setting.key === 'EMAIL_PROVIDER') { + return parseBoolean(getSettingValue(settings, 'EMAIL_ENABLED')); + } + if (setting.key === 'SMS_PROVIDER') { + return parseBoolean(getSettingValue(settings, 'SMS_ENABLED')); + } + + return true; +} + +export function MessagingSettingsSection({ + settings, + token, + savingKey, + onUpdateValue, + onSave, + showToast +}: MessagingSettingsSectionProps) { + const [testEmail, setTestEmail] = useState(''); + const [testPhone, setTestPhone] = useState(''); + const [testPhoneCountry, setTestPhoneCountry] = useState(phoneCountries[0]); + const [testingChannel, setTestingChannel] = useState<'email' | 'sms' | null>(null); + + const messagingSettings = useMemo( + () => settings.filter((item) => MESSAGING_SETTING_KEYS.includes(item.key)), + [settings] + ); + + async function sendTest(channel: 'email' | 'sms') { + if (!token) return; + const target = channel === 'email' ? testEmail.trim() : `${testPhoneCountry.code}${testPhone}`; + if (!target) { + showToast(channel === 'email' ? 'Укажите email для теста' : 'Укажите номер телефона для теста'); + return; + } + + setTestingChannel(channel); + try { + const response = await apiFetch<{ message?: string }>( + '/admin/settings/messaging/test', + { + method: 'POST', + body: JSON.stringify({ channel, target }) + }, + token + ); + showToast(response.message ?? 'Тестовое сообщение отправлено'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось отправить тестовое сообщение'); + } finally { + setTestingChannel(null); + } + } + + const emailEnabled = parseBoolean(getSettingValue(messagingSettings, 'EMAIL_ENABLED')); + const smsEnabled = parseBoolean(getSettingValue(messagingSettings, 'SMS_ENABLED')); + + return ( +
+
+ +
+

Email и SMS провайдеры

+

+ Настройте SMTP и SMS — OTP-коды при входе и регистрации будут отправляться автоматически. +

+
+
+ +
+ {messagingSettings.filter((setting) => shouldShowMessagingField(setting, messagingSettings)).map((setting) => { + const meta = getSettingMeta(setting.key); + const label = meta?.label ?? setting.key; + const type = meta?.type ?? 'text'; + + return ( +
+
+
+
{label}
+

{meta?.hint ?? setting.description ?? setting.key}

+
+ +
+ {type === 'boolean' ? ( + + ) : type === 'select' ? ( + + ) : ( +
+ onUpdateValue(setting.key, event.target.value)} + /> + {meta?.unit ? {meta.unit} : null} +
+ )} + + +
+
+
+ ); + })} +
+ +
+
+
+ + Тест email +
+

+ {emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'} +

+ setTestEmail(event.target.value)} + /> + +
+ +
+
+ + Тест SMS +
+

+ {smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'} +

+
+ +
+ +
+
+
+ ); +} diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index 3c59ffb..5235eb5 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -1,4 +1,4 @@ -export type SystemSettingFieldType = 'text' | 'number' | 'boolean'; +export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select'; export interface SystemSettingMeta { key: string; @@ -7,6 +7,8 @@ export interface SystemSettingMeta { type: SystemSettingFieldType; unit?: string; hint?: string; + options?: Array<{ value: string; label: string }>; + showWhen?: { key: string; equals: string }; } export const SYSTEM_SETTING_GROUPS: Record = { @@ -14,6 +16,7 @@ export const SYSTEM_SETTING_GROUPS: Record = { pin: 'PIN-код и блокировка сессии', auth: 'Аутентификация и регистрация', ldap: 'LDAP / Active Directory', + messaging: 'Email и SMS', limits: 'Лимиты и ограничения', media: 'Медиа и файлы' }; @@ -47,6 +50,42 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ { key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' }, { key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' }, { key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' }, + { key: 'EMAIL_ENABLED', label: 'Email включён', group: 'messaging', type: 'boolean', hint: 'Отправка OTP-кодов на почту через SMTP' }, + { + key: 'EMAIL_PROVIDER', + label: 'Email-провайдер', + group: 'messaging', + type: 'select', + options: [ + { value: 'smtp', label: 'SMTP (Yandex, Gmail, Mail.ru и др.)' }, + { value: 'console', label: 'Только лог (отладка)' } + ], + showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } + }, + { key: 'EMAIL_SMTP_HOST', label: 'SMTP-хост', group: 'messaging', type: 'text', hint: 'smtp.yandex.ru, smtp.gmail.com', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_SMTP_PORT', label: 'SMTP-порт', group: 'messaging', type: 'number', unit: 'порт', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_SMTP_SECURE', label: 'SMTP SSL/TLS', group: 'messaging', type: 'boolean', hint: 'Включите для порта 465', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_SMTP_USER', label: 'SMTP-логин', group: 'messaging', type: 'text', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_SMTP_PASSWORD', label: 'SMTP-пароль', group: 'messaging', type: 'text', hint: 'App-password для Gmail/Yandex', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_FROM_ADDRESS', label: 'Email отправителя', group: 'messaging', type: 'text', hint: 'noreply@yourdomain.ru', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'EMAIL_FROM_NAME', label: 'Имя отправителя', group: 'messaging', type: 'text', showWhen: { key: 'EMAIL_ENABLED', equals: 'true' } }, + { key: 'SMS_ENABLED', label: 'SMS включён', group: 'messaging', type: 'boolean', hint: 'Отправка OTP-кодов по SMS' }, + { + key: 'SMS_PROVIDER', + label: 'SMS-провайдер', + group: 'messaging', + type: 'select', + options: [ + { value: 'smsru', label: 'sms.ru' }, + { value: 'smsc', label: 'smsc.ru' }, + { value: 'console', label: 'Только лог (отладка)' } + ], + showWhen: { key: 'SMS_ENABLED', equals: 'true' } + }, + { key: 'SMS_API_KEY', label: 'API-ключ sms.ru', group: 'messaging', type: 'text', hint: 'api_id из личного кабинета sms.ru', showWhen: { key: 'SMS_ENABLED', equals: 'true' } }, + { key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } }, + { key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } }, + { key: 'SMS_SENDER', label: 'Имя отправителя SMS', group: 'messaging', type: 'text', hint: 'Опционально, если одобрено провайдером', showWhen: { key: 'SMS_ENABLED', equals: 'true' } }, { key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' }, { key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' }, { key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' } @@ -56,6 +95,20 @@ export function getSettingMeta(key: string) { return SYSTEM_SETTING_CATALOG.find((item) => item.key === key); } +export function isSettingVisible(setting: { key: string; value: string }, allSettings: Array<{ key: string; value: string }>) { + const meta = getSettingMeta(setting.key); + if (!meta?.showWhen) return true; + const parent = allSettings.find((item) => item.key === meta.showWhen!.key); + const parentValue = parent?.value ?? 'false'; + return parseBooleanValue(parentValue) === parseBooleanValue(meta.showWhen.equals); +} + +function parseBooleanValue(value: string) { + return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); +} + +export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key); + export function sortSettingsByCatalog(settings: T[]) { const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index])); return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999)); diff --git a/apps/sso-core/package.json b/apps/sso-core/package.json index 42a5b96..bef8476 100644 --- a/apps/sso-core/package.json +++ b/apps/sso-core/package.json @@ -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", diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index 956d0de..272c5f2 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -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 {} diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index d9d3d8e..9cbdadc 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -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); diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index caa81e6..e94f470 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -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 { @@ -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('@'); diff --git a/apps/sso-core/src/domain/otp.service.ts b/apps/sso-core/src/domain/otp.service.ts index e9c0b5d..7937d14 100644 --- a/apps/sso-core/src/domain/otp.service.ts +++ b/apps/sso-core/src/domain/otp.service.ts @@ -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()}`; + } } diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index 8746518..6806475 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -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', diff --git a/apps/sso-core/src/infra/messaging.service.ts b/apps/sso-core/src/infra/messaging.service.ts new file mode 100644 index 0000000..ad9014b --- /dev/null +++ b/apps/sso-core/src/infra/messaging.service.ts @@ -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 { + 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 ` +
+

Ваш код подтверждения ${projectName}:

+

${code}

+

Код действителен ${expiryMinutes} мин.

+

Если вы не запрашивали код — проигнорируйте это письмо.

+
+ `.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)}`; + } +} diff --git a/install.sh b/install.sh index 53552c6..221cc91 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ set -euo pipefail -SCRIPT_VERSION="2.2.1" +SCRIPT_VERSION="2.2.2" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" @@ -228,7 +228,9 @@ lendry_host_nginx_configured() { } stop_docker_nginx() { - if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then + local cid + cid="$(docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null || true)" + if [[ -n "$cid" ]]; then log "Останавливаем контейнер lendry-id-nginx (используется системный Nginx)..." docker_cmd compose --env-file .env --profile proxy stop nginx 2>/dev/null || true docker_cmd rm -f lendry-id-nginx 2>/dev/null || true @@ -325,6 +327,14 @@ compose_uses_proxy_profile() { return 0 } +# Заполняет глобальный массив COMPOSE_STACK_CMD для docker compose +build_compose_stack_cmd() { + COMPOSE_STACK_CMD=(compose --env-file .env) + if compose_uses_proxy_profile; then + COMPOSE_STACK_CMD+=(--profile proxy) + fi +} + install_packages_apt() { run_root apt-get update -qq run_root apt-get install -y -qq ca-certificates curl git openssl gnupg lsb-release jq @@ -968,11 +978,15 @@ recreate_host_port_services() { [[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0 log "Пересоздание сервисов с пробросом портов на 127.0.0.1..." - local compose_cmd=(compose --env-file .env) - if compose_uses_proxy_profile; then - compose_cmd+=(--profile proxy) - fi - docker_cmd "${compose_cmd[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}" + build_compose_stack_cmd + docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}" +} + +docker_publishes_port() { + local container="$1" + local host_port="$2" + docker_cmd port "$container" 2>/dev/null | grep -q "127.0.0.1:${host_port}" || \ + docker_cmd port "$container" 2>/dev/null | grep -q ":${host_port}->" } ensure_host_upstreams() { @@ -1470,8 +1484,11 @@ write_all_nginx_configs() { [[ -n "${DOMAIN_MINIO_CONSOLE:-}" ]] && write_nginx_site_minio "$DOMAIN_MINIO_CONSOLE" minio-console minio-console "$ssl_type" if [[ "$NGINX_MODE" == "host" ]]; then - run_root nginx -t - run_root systemctl reload nginx + if run_root nginx -t; then + run_root systemctl reload nginx || warn "Не удалось перезагрузить Nginx (systemctl reload nginx)" + else + warn "Конфигурация Nginx содержит ошибки — проверьте сертификаты в ${LOCAL_CERTS_DIR}" + fi else ok "Конфиги Nginx: ${LOCAL_CONF_DIR}" fi @@ -1613,13 +1630,10 @@ deploy_stack() { prepare_compose_stack verify_postgres_auth - local compose_cmd=(compose --env-file .env) - if compose_uses_proxy_profile; then - compose_cmd+=(--profile proxy) - fi + build_compose_stack_cmd log "Сборка и запуск контейнеров (10–20 минут при первом запуске)..." - docker_cmd "${compose_cmd[@]}" up -d --build + docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build recreate_host_port_services ensure_host_upstreams @@ -1696,9 +1710,14 @@ refresh_host_nginx_configs() { local ssl_type ssl_type="$(env_get SSL_TYPE none)" case "$ssl_type" in - none) write_nginx_http_only ;; - selfsigned) write_all_nginx_configs "selfsigned" ;; - letsencrypt) write_all_nginx_configs "letsencrypt" ;; + none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx (нужен sudo?)" ;; + selfsigned) + generate_all_self_signed_certs + write_all_nginx_configs "selfsigned" || warn "Не удалось обновить Nginx (нужен sudo?)" + ;; + letsencrypt) + write_all_nginx_configs "letsencrypt" || warn "Не удалось обновить Nginx (нужен sudo?)" + ;; esac } @@ -1817,23 +1836,25 @@ action_renew_ssl() { action_fix_host_proxy() { [[ -f .env ]] || fail "Файл .env не найден — сначала выполните ./install.sh --install" load_env - NGINX_MODE="host" env_set NGINX_MODE "host" + NGINX_MODE="host" NON_INTERACTIVE=1 - log "Исправление прокси: системный Nginx + проброс портов на 127.0.0.1..." - stop_docker_nginx - prepare_compose_stack - refresh_host_nginx_configs + log "Исправление прокси (host Nginx + порты 127.0.0.1)..." - local compose_cmd=(compose --env-file .env) - docker_cmd "${compose_cmd[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}" + log "Шаг 1/4: остановка Docker Nginx..." + stop_docker_nginx + + log "Шаг 2/4: удаление override (мог блокировать порты)..." + rm -f "$COMPOSE_OVERRIDE" + + log "Шаг 3/4: пересоздание api-gateway, frontend, docs, media-ws..." + build_compose_stack_cmd + docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --force-recreate "${HOST_EDGE_SERVICES[@]}" ensure_host_upstreams - if system_nginx_is_active; then - run_root nginx -t - run_root systemctl reload nginx - fi + log "Шаг 4/4: конфиги системного Nginx..." + refresh_host_nginx_configs ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002" show_status @@ -1843,11 +1864,8 @@ action_restart() { load_env prepare_compose_stack refresh_host_nginx_configs - local compose_cmd=(compose --env-file .env) - if compose_uses_proxy_profile; then - compose_cmd+=(--profile proxy) - fi - docker_cmd "${compose_cmd[@]}" up -d --build + build_compose_stack_cmd + docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build recreate_host_port_services ensure_host_upstreams ok "Контейнеры перезапущены" diff --git a/package-lock.json b/package-lock.json index fc3c08b..c34a40c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -187,6 +187,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", @@ -198,6 +199,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", @@ -5682,6 +5684,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -9009,6 +9021,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", diff --git a/shared/proto/security.proto b/shared/proto/security.proto index c1b4864..9f621e7 100644 --- a/shared/proto/security.proto +++ b/shared/proto/security.proto @@ -29,6 +29,7 @@ service SettingsService { rpc ListSocialProviders (Empty) returns (ListSocialProvidersResponse); rpc UpsertSocialProvider (UpsertSocialProviderRequest) returns (SocialProvider); rpc DeleteSocialProvider (ProviderNameRequest) returns (MutationCountResponse); + rpc TestMessagingDelivery (TestMessagingDeliveryRequest) returns (TestMessagingDeliveryResponse); } message Empty {} @@ -216,3 +217,15 @@ message ListPublicSettingsResponse { message MutationCountResponse { int32 count = 1; } + +message TestMessagingDeliveryRequest { + string channel = 1; + string target = 2; +} + +message TestMessagingDeliveryResponse { + bool sent = 1; + string channel = 2; + string target = 3; + optional string message = 4; +}