This commit is contained in:
lendry
2026-06-25 07:23:34 +03:00
parent f2366a69a0
commit 71b270fcb3
19 changed files with 1410 additions and 112 deletions

View File

@@ -31,6 +31,7 @@ import { NotificationPublisherService } from './infra/notification-publisher.ser
import { MinioService } from './infra/minio.service';
import { LdapClientService } from './infra/ldap-client.service';
import { MessagingService } from './infra/messaging.service';
import { SmsService } from './infra/sms.service';
import { TotpService } from './domain/totp.service';
@Module({
@@ -68,7 +69,8 @@ import { TotpService } from './domain/totp.service';
NotificationPublisherService,
MinioService,
LdapClientService,
MessagingService
MessagingService,
SmsService
]
})
export class AppModule {}

View File

@@ -514,6 +514,34 @@ export class AuthGrpcController {
return this.profile.setPassword(command.userId, command.password);
}
@GrpcMethod('ProfileService', 'SendPasswordVerificationOtp')
sendPasswordVerificationOtp(command: { userId: string; channel: string }) {
return this.profile.sendPasswordVerificationOtp(command.userId, command.channel as 'sms' | 'email');
}
@GrpcMethod('ProfileService', 'ChangePassword')
changePassword(command: {
userId: string;
newPassword: string;
currentPassword?: string;
otpCode?: string;
otpChannel?: string;
totpCode?: string;
}) {
return this.profile.changePassword(command.userId, command.newPassword, command);
}
@GrpcMethod('ProfileService', 'RemovePassword')
removePassword(command: {
userId: string;
currentPassword?: string;
otpCode?: string;
otpChannel?: string;
totpCode?: string;
}) {
return this.profile.removePassword(command.userId, command);
}
@GrpcMethod('ProfileService', 'SoftDeleteProfile')
softDeleteProfile(command: { userId: string }) {
return this.profile.softDeleteProfile(command.userId);

View File

@@ -716,7 +716,7 @@ export class AuthService {
return this.toPublicUser(user);
}
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status'>): Promise<PublicUser> {
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status' | 'passwordHash'>): Promise<PublicUser> {
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
return {
id: user.id,
@@ -730,6 +730,7 @@ export class AuthService {
hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
isSuperAdmin: user.isSuperAdmin,
status: user.status,
hasPassword: Boolean(user.passwordHash),
roles: access.roles,
permissions: access.permissions,
canAccessAdmin: access.canAccessAdmin,

View File

@@ -48,6 +48,7 @@ export interface PublicUser {
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
hasPassword?: boolean;
}
export interface VerifyPinCommand {

View File

@@ -97,6 +97,8 @@ export class OtpService {
return 'Вход в аккаунт';
case 'password-reset':
return 'Сброс пароля';
case 'password-change':
return 'Смена пароля';
default:
return 'Подтверждение действия';
}

View File

@@ -1,10 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { SessionStatus, UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { OtpService } from './otp.service';
import { TotpService } from './totp.service';
@@ -58,11 +60,22 @@ function prefixNullable(userId: string, value: string | null | undefined): strin
interface PasswordVerificationCommand {
currentPassword?: string;
otpCode?: string;
otpChannel?: string;
totpCode?: string;
}
@Injectable()
export class ProfileService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly otp: OtpService,
private readonly totp: TotpService
) {}
@@ -215,6 +228,20 @@ export class ProfileService {
}
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
if (!existing) {
throw new NotFoundException('Пользователь не найден');
}
if (existing.passwordHash) {
throw new BadRequestException('Пароль уже установлен. Для смены подтвердите личность.');
}
const user = await this.prisma.user.update({
where: { id: userId },
@@ -227,6 +254,164 @@ export class ProfileService {
}
async sendPasswordVerificationOtp(userId: string, channel: 'sms' | 'email') {
const user = await this.requireUserWithPassword(userId);
const target = channel === 'sms' ? user.phone ?? user.backupPhone : user.email ?? user.backupEmail;
if (!target) {
throw new BadRequestException(
channel === 'sms' ? 'Не указан телефон для отправки SMS-кода' : 'Не указана почта для отправки кода'
);
}
await this.otp.send({
target,
channel,
purpose: 'password-change',
userId
});
return { sent: true, maskedTarget: this.maskVerificationTarget(target, channel) };
}
async changePassword(userId: string, newPassword: string, verification: PasswordVerificationCommand) {
if (!newPassword || newPassword.length < 8) {
throw new BadRequestException('Новый пароль должен содержать минимум 8 символов');
}
const user = await this.requireUserWithPassword(userId);
await this.assertPasswordVerification(userId, user, verification);
const updated = await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: await bcrypt.hash(newPassword, 12) }
});
return { hasPassword: Boolean(updated.passwordHash) };
}
async removePassword(userId: string, verification: PasswordVerificationCommand) {
const user = await this.requireUserWithPassword(userId);
await this.assertPasswordVerification(userId, user, verification);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: null }
});
return { hasPassword: false };
}
private async requireUserWithPassword(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('Пользователь не найден');
}
if (!user.passwordHash) {
throw new BadRequestException('Пароль не установлен');
}
return user;
}
private async assertPasswordVerification(
userId: string,
user: { passwordHash: string | null; email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null },
verification: PasswordVerificationCommand
) {
if (verification.currentPassword?.trim()) {
const matches = await bcrypt.compare(verification.currentPassword.trim(), user.passwordHash!);
if (!matches) {
throw new UnauthorizedException('Неверный текущий пароль');
}
return;
}
if (verification.otpCode?.trim() && verification.otpChannel) {
const channel = verification.otpChannel === 'sms' ? 'sms' : 'email';
const target = channel === 'sms' ? user.phone ?? user.backupPhone : user.email ?? user.backupEmail;
if (!target) {
throw new BadRequestException('Контакт для проверки OTP не найден');
}
await this.otp.verify({ target, code: verification.otpCode.trim(), purpose: 'password-change' });
return;
}
if (verification.totpCode?.trim()) {
const isValid = await this.totp.verifyEnabledCode(userId, verification.totpCode.trim());
if (!isValid) {
throw new UnauthorizedException('Неверный код аутентификатора');
}
return;
}
throw new BadRequestException(
'Подтвердите личность: укажите текущий пароль, код OTP или код из приложения-аутентификатора'
);
}
private maskVerificationTarget(target: string, channel: 'sms' | 'email') {
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)}`;
}
async softDeleteProfile(userId: string) {
@@ -399,7 +584,9 @@ export class ProfileService {
backupPhone: user.backupPhone,
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null,
hasPassword: Boolean(user.passwordHash)
};

View File

@@ -47,14 +47,40 @@ export const DEFAULT_SYSTEM_SETTINGS = [
{ 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_PROVIDER', value: 'smsru', description: 'SMS-провайдер: smsru, smsc, huawei_modem или 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 (если поддерживается провайдером)' }
{ key: 'SMS_SENDER', value: '', description: 'Имя отправителя SMS (если поддерживается провайдером)' },
{
key: 'SMS_GATEWAY_URL',
value: '',
description: 'URL локального SMS-шлюза Huawei HiLink, например http://192.168.8.1'
},
{
key: 'SMS_GATEWAY_USERNAME',
value: 'admin',
description: 'Логин веб-интерфейса модема Huawei HiLink (обычно admin)'
},
{
key: 'SMS_GATEWAY_PASSWORD',
value: '',
description: 'Пароль веб-интерфейса модема Huawei HiLink'
},
{
key: 'SMS_GATEWAY_MESSAGE_TEMPLATE',
value: '{{appname}}: код {{code}}. Действует {{expiry}} мин.',
description: 'Шаблон текста SMS для Huawei HiLink с тегами {{appname}}, {{code}}, {{expiry}}'
}
] as const;
const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD', 'EMAIL_SMTP_PASSWORD', 'SMS_API_KEY', 'SMS_PASSWORD']);
const SECRET_SETTING_KEYS = new Set([
'LDAP_BIND_PASSWORD',
'EMAIL_SMTP_PASSWORD',
'SMS_API_KEY',
'SMS_PASSWORD',
'SMS_GATEWAY_PASSWORD'
]);
export const PUBLIC_SETTING_KEYS = [
'PROJECT_NAME',

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import nodemailer from 'nodemailer';
import { SettingsService } from '../domain/settings.service';
import { SmsService } from './sms.service';
type MessagingChannel = 'email' | 'sms';
@@ -15,7 +16,10 @@ interface VerificationPayload {
export class MessagingService {
private readonly logger = new Logger(MessagingService.name);
constructor(private readonly settings: SettingsService) {}
constructor(
private readonly settings: SettingsService,
private readonly smsService: SmsService
) {}
async sendVerificationCode(payload: VerificationPayload): Promise<void> {
if (payload.channel === 'email') {
@@ -122,7 +126,10 @@ export class MessagingService {
const projectName = await this.settings.getValue('PROJECT_NAME', 'MVK ID');
const phone = this.normalizePhone(payload.target);
const text = `${projectName}: код ${payload.code}. Действует ${payload.expiryMinutes} мин.`;
const text =
provider === 'huawei_modem'
? await this.buildHuaweiSmsText(projectName, payload.code, payload.expiryMinutes)
: `${projectName}: код ${payload.code}. Действует ${payload.expiryMinutes} мин.`;
if (provider === 'smsru') {
await this.sendViaSmsRu(phone, text);
@@ -134,6 +141,14 @@ export class MessagingService {
return;
}
if (provider === 'huawei_modem') {
const sent = await this.smsService.sendOtp(phone, text);
if (!sent) {
throw new BadRequestException('Не удалось отправить SMS через локальный шлюз Huawei');
}
return;
}
throw new BadRequestException(`Неизвестный SMS-провайдер: ${provider}`);
}
@@ -189,6 +204,31 @@ export class MessagingService {
}
}
private async buildHuaweiSmsText(projectName: string, code: string, expiryMinutes: number) {
const template = (await this.settings.getValue('SMS_GATEWAY_MESSAGE_TEMPLATE', '')).trim();
const fallback = `${projectName}: код ${code}. Действует ${expiryMinutes} мин.`;
if (!template) {
return fallback;
}
const rendered = this.applySmsTemplate(template, {
appname: projectName,
code,
expiry: String(expiryMinutes),
expiryminutes: String(expiryMinutes)
});
return rendered.trim() || fallback;
}
private applySmsTemplate(template: string, variables: Record<string, string>) {
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, rawKey: string) => {
const key = rawKey.toLowerCase();
return variables[key] ?? match;
});
}
private normalizePhone(value: string) {
const digits = value.replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('8')) return `7${digits.slice(1)}`;

View File

@@ -0,0 +1,573 @@
import { createHash } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SettingsService } from '../domain/settings.service';
interface ModemSessionTokens {
sesInfo: string;
tokInfo: string;
}
interface ModemCredentials {
username: string;
password: string;
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
private readonly requestTimeoutMs = 15_000;
private readonly logTokenChunkSize = 60;
constructor(
private readonly settings: SettingsService,
private readonly config: ConfigService
) {}
async sendOtp(phoneNumber: string, messageText: string): Promise<boolean> {
let modemUrl = '';
try {
modemUrl = await this.resolveModemUrl();
const tokens = await this.fetchSessionTokens(modemUrl);
await this.postSms(modemUrl, tokens, phoneNumber, messageText);
this.logger.log(`SMS отправлено через Huawei HiLink на ${this.maskPhone(phoneNumber)}`);
return true;
} catch (error) {
const message = this.formatError(error, modemUrl);
this.logger.error(`Не удалось отправить SMS через Huawei HiLink: ${message}`);
return false;
}
}
private async resolveModemUrl(): Promise<string> {
const fromDb = (await this.settings.getValue('SMS_GATEWAY_URL', '')).trim();
if (fromDb) {
return this.normalizeModemUrl(fromDb);
}
const fromEnv = (this.config.get<string>('HUAWEI_MODEM_URL') ?? '').trim();
if (fromEnv) {
return this.normalizeModemUrl(fromEnv);
}
return 'http://192.168.8.1';
}
private async resolveModemCredentials(): Promise<ModemCredentials> {
const usernameFromDb = (await this.settings.getValue('SMS_GATEWAY_USERNAME', '')).trim();
const passwordFromDb = (await this.settings.getValue('SMS_GATEWAY_PASSWORD', '')).trim();
const username =
usernameFromDb ||
(this.config.get<string>('HUAWEI_MODEM_USERNAME') ?? '').trim() ||
'admin';
const password =
passwordFromDb || (this.config.get<string>('HUAWEI_MODEM_PASSWORD') ?? '').trim();
return { username, password };
}
private normalizeModemUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(trimmed)) {
return `http://${trimmed}`;
}
return trimmed;
}
private async fetchSessionTokens(modemUrl: string): Promise<ModemSessionTokens> {
const initial = await this.requestSesTokInfo(modemUrl);
let sessionCookie = initial.sesInfo;
this.logDebugBlock('SesTokInfo (initial) Cookie / SesInfo', sessionCookie);
this.logDebugBlock('SesTokInfo (initial) TokInfo', initial.tokInfo);
const loginState = await this.resolveModemLoginState(modemUrl, sessionCookie);
if (loginState.required) {
const credentials = await this.resolveModemCredentials();
if (!credentials.password) {
throw new Error(
'Модем Huawei требует авторизацию (код 100003). Укажите SMS_GATEWAY_USERNAME и SMS_GATEWAY_PASSWORD в настройках SMS или переменные HUAWEI_MODEM_USERNAME / HUAWEI_MODEM_PASSWORD'
);
}
this.logger.debug(
`Huawei HiLink: выполняется вход под пользователем ${credentials.username}`
);
const loggedIn = await this.loginToModem(
modemUrl,
sessionCookie,
initial.tokInfo,
credentials,
loginState.passwordType
);
sessionCookie = loggedIn.sesInfo;
this.logDebugBlock('Huawei login: новый Cookie / SesInfo', sessionCookie);
this.logDebugBlock('Huawei login: __RequestVerificationToken', loggedIn.tokInfo);
}
const refreshed = await this.requestSesTokInfo(modemUrl, sessionCookie);
this.logDebugBlock('SesTokInfo (refreshed) Cookie / SesInfo', refreshed.sesInfo);
this.logDebugBlock('SesTokInfo (refreshed) TokInfo for POST', refreshed.tokInfo);
return {
sesInfo: refreshed.sesInfo,
tokInfo: refreshed.tokInfo
};
}
private async resolveModemLoginState(
modemUrl: string,
sessionCookie: string
): Promise<{ required: boolean; passwordType: string }> {
const loginDisabled = await this.isModemLoginDisabled(modemUrl, sessionCookie);
if (loginDisabled) {
return { required: false, passwordType: '4' };
}
const { loggedIn, passwordType } = await this.fetchLoginState(modemUrl, sessionCookie);
return { required: !loggedIn, passwordType };
}
private async isModemLoginDisabled(modemUrl: string, sessionCookie: string): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(`${modemUrl}/config/global/config.xml`, {
method: 'GET',
headers: {
Cookie: sessionCookie,
Accept: '*/*',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
return false;
}
const configXml = await response.text();
const loginFlag = this.extractXmlValue(configXml, 'login');
return loginFlag === '0';
} catch {
return false;
}
}
private async fetchLoginState(
modemUrl: string,
sessionCookie: string
): Promise<{ loggedIn: boolean; passwordType: string; state: string | null }> {
const response = await this.fetchWithTimeout(`${modemUrl}/api/user/state-login`, {
method: 'GET',
headers: {
Cookie: sessionCookie,
Accept: '*/*',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`state-login HTTP ${response.status}`);
}
const xml = await response.text();
this.logger.debug(`state-login raw XML:\n${this.formatMultilineForLog(xml, 120, false)}`);
const state = this.extractXmlValue(xml, 'State');
const passwordType = this.extractXmlValue(xml, 'password_type') ?? '4';
const loggedIn = state === '0';
this.logger.debug(
`state-login: State=${state ?? 'unknown'}, loggedIn=${loggedIn}, password_type=${passwordType}`
);
return { loggedIn, passwordType, state };
}
private async loginToModem(
modemUrl: string,
sessionCookie: string,
verificationToken: string,
credentials: ModemCredentials,
passwordType: string
): Promise<ModemSessionTokens> {
const encodedPassword = this.encodeHuaweiPassword(
credentials.username,
credentials.password,
verificationToken
);
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<request>',
` <Username>${this.escapeXml(credentials.username)}</Username>`,
` <Password>${encodedPassword}</Password>`,
` <password_type>${this.escapeXml(passwordType)}</password_type>`,
'</request>'
].join('\n');
this.logDebugBlock('Huawei login POST Cookie', sessionCookie);
this.logDebugBlock('Huawei login POST __RequestVerificationToken', verificationToken);
console.log('[SmsService] Huawei login POST body:\n' + body);
const response = await this.fetchWithTimeout(`${modemUrl}/api/user/login`, {
method: 'POST',
headers: {
Cookie: sessionCookie,
__RequestVerificationToken: verificationToken,
'Content-Type': 'text/xml; charset=UTF-8',
Accept: '*/*',
'X-Requested-With': 'XMLHttpRequest',
Origin: modemUrl,
Referer: `${modemUrl}/html/index.html`
},
body
});
const responseText = await response.text();
this.logger.debug(`login response:\n${this.formatMultilineForLog(responseText, 120, false)}`);
const errorCode = this.extractXmlValue(responseText, 'code');
if (errorCode && errorCode !== '0') {
const errorMessage = this.extractXmlValue(responseText, 'message');
throw new Error(
errorMessage?.trim() || `login: код ошибки модема ${errorCode}`
);
}
if (!/OK/i.test(responseText) && !response.ok) {
throw new Error(`login HTTP ${response.status}: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`);
}
const newCookie =
this.extractSessionCookieFromHeaders(response) ??
this.normalizeSesInfoCookie(this.extractXmlValue(responseText, 'SesInfo') ?? sessionCookie);
const newToken =
this.extractVerificationTokenFromHeaders(response) ?? verificationToken;
return {
sesInfo: newCookie,
tokInfo: newToken
};
}
private encodeHuaweiPassword(username: string, rawPassword: string, token: string): string {
const sha256Hex = (value: string) =>
createHash('sha256').update(value, 'utf8').digest('hex');
const passwordHashB64 = Buffer.from(sha256Hex(rawPassword), 'utf8').toString('base64');
const combined = username + passwordHashB64 + token;
return Buffer.from(sha256Hex(combined), 'utf8').toString('base64');
}
private async requestSesTokInfo(modemUrl: string, sessionCookie?: string): Promise<ModemSessionTokens> {
const headers: Record<string, string> = {
Accept: '*/*',
'X-Requested-With': 'XMLHttpRequest'
};
if (sessionCookie) {
headers.Cookie = sessionCookie;
}
const response = await this.fetchWithTimeout(`${modemUrl}/api/webserver/SesTokInfo`, {
method: 'GET',
headers
});
if (!response.ok) {
throw new Error(`SesTokInfo HTTP ${response.status}`);
}
const xml = await response.text();
this.logger.debug(