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,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 пользователя' })

View File

@@ -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: 'Название провайдера должно быть строкой' })

View File

@@ -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<string, SystemSetting[]>();
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() {
<section key={groupKey} className="mb-10">
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
<div className="space-y-3">
{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) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
</button>
) : type === 'select' ? (
<select
className="h-10 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
value={setting.value}
onChange={(event) => updateSettingValue(setting.key, event.target.value)}
>
{(meta?.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2">
<Input
@@ -178,6 +192,15 @@ export default function AdminSettingsPage() {
</section>
))}
<MessagingSettingsSection
settings={settings}
token={token}
savingKey={savingKey}
onUpdateValue={updateSettingValue}
onSave={saveSetting}
showToast={showToast}
/>
<section className="mt-10">
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
<div className="mt-4 grid gap-4 md:grid-cols-2">

View File

@@ -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<void>;
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 (
<section className="mb-10">
<div className="mb-4 flex items-center gap-3">
<MessageSquare className="h-6 w-6" />
<div>
<h3 className="text-xl font-medium">Email и SMS провайдеры</h3>
<p className="mt-1 text-sm text-[#667085]">
Настройте SMTP и SMS OTP-коды при входе и регистрации будут отправляться автоматически.
</p>
</div>
</div>
<div className="space-y-3">
{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 (
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1">
<div className="font-semibold">{label}</div>
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{type === 'boolean' ? (
<button
type="button"
aria-label={label}
onClick={() => onUpdateValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true')}
>
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
</button>
) : type === 'select' ? (
<select
className="h-10 rounded-xl border border-[#e4e7ec] bg-white px-3 text-sm outline-none focus:border-[#98a2b3]"
value={setting.value}
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
>
{(meta?.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2">
<Input
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
value={setting.value}
className={setting.isSecret ? 'w-[240px] bg-white' : 'w-[220px] bg-white'}
onChange={(event) => onUpdateValue(setting.key, event.target.value)}
/>
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
</div>
)}
<Button disabled={savingKey === setting.key} onClick={() => void onSave(setting)}>
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить
</Button>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-[24px] border border-[#e4e7ec] bg-white p-5">
<div className="flex items-center gap-2 font-semibold">
<Mail className="h-5 w-5" />
Тест email
</div>
<p className="mt-2 text-sm text-[#667085]">
{emailEnabled ? 'Отправит тестовый OTP-код через SMTP.' : 'Сначала включите Email и сохраните настройки SMTP.'}
</p>
<Input
className="mt-4 bg-[#f9fafb]"
type="email"
placeholder="admin@example.com"
value={testEmail}
onChange={(event) => setTestEmail(event.target.value)}
/>
<Button className="mt-3" disabled={!emailEnabled || testingChannel === 'email'} onClick={() => void sendTest('email')}>
{testingChannel === 'email' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Отправить тест
</Button>
</div>
<div className="rounded-[24px] border border-[#e4e7ec] bg-white p-5">
<div className="flex items-center gap-2 font-semibold">
<MessageSquare className="h-5 w-5" />
Тест SMS
</div>
<p className="mt-2 text-sm text-[#667085]">
{smsEnabled ? 'Отправит тестовый OTP-код через SMS-провайдера.' : 'Сначала включите SMS и сохраните ключ API.'}
</p>
<div className="mt-4">
<PhoneInput
country={testPhoneCountry}
value={testPhone}
onCountryChange={setTestPhoneCountry}
onValueChange={setTestPhone}
/>
</div>
<Button className="mt-3" disabled={!smsEnabled || testingChannel === 'sms'} onClick={() => void sendTest('sms')}>
{testingChannel === 'sms' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Отправить тест
</Button>
</div>
</div>
</section>
);
}

View File

@@ -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<string, string> = {
@@ -14,6 +16,7 @@ export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
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<T extends { key: string }>(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));

View File

@@ -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",

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)}`;
}
}

View File

@@ -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 "Сборка и запуск контейнеров (1020 минут при первом запуске)..."
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 "Контейнеры перезапущены"

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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;
}