update
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface PublicUser {
|
||||
canManageSettings?: boolean;
|
||||
canViewUsers?: boolean;
|
||||
canViewUserDocuments?: boolean;
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyPinCommand {
|
||||
|
||||
@@ -97,6 +97,8 @@ export class OtpService {
|
||||
return 'Вход в аккаунт';
|
||||
case 'password-reset':
|
||||
return 'Сброс пароля';
|
||||
case 'password-change':
|
||||
return 'Смена пароля';
|
||||
default:
|
||||
return 'Подтверждение действия';
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user