update
This commit is contained in:
@@ -1,83 +1,128 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto';
|
||||
|
||||
|
||||
|
||||
@ApiTags('Профиль и биометрия')
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@Controller('profile/users/:userId')
|
||||
|
||||
export class ProfileController {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly core: CoreGrpcService,
|
||||
|
||||
private readonly jwt: JwtService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
private async assertSelfAccess(authorization: string | undefined, userId: string) {
|
||||
|
||||
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
|
||||
if (requesterId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно изменять только свой профиль');
|
||||
|
||||
}
|
||||
|
||||
return requesterId;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get()
|
||||
|
||||
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
|
||||
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
|
||||
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
|
||||
|
||||
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
|
||||
|
||||
getProfile(@Param('userId') userId: string) {
|
||||
|
||||
return this.core.profile.GetProfile({ userId });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Patch('avatar')
|
||||
|
||||
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
|
||||
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
|
||||
@ApiBody({ type: UpdateAvatarDto })
|
||||
|
||||
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
|
||||
|
||||
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
|
||||
|
||||
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
|
||||
|
||||
}
|
||||
|
||||
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import {
|
||||
ChangePasswordDto,
|
||||
PasswordVerificationDto,
|
||||
SendPasswordVerificationOtpDto,
|
||||
SetPasswordDto,
|
||||
UpdateAvatarDto,
|
||||
UpdateContactsDto,
|
||||
UpdateProfileDto
|
||||
} from '../dto/profile.dto';
|
||||
@ApiTags('Профиль и биометрия')
|
||||
@ApiBearerAuth()
|
||||
@Controller('profile/users/:userId')
|
||||
export class ProfileController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async assertSelfAccess(authorization: string | undefined, userId: string) {
|
||||
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
if (requesterId !== userId) {
|
||||
throw new ForbiddenException('Можно изменять только свой профиль');
|
||||
}
|
||||
return requesterId;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
|
||||
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
|
||||
getProfile(@Param('userId') userId: string) {
|
||||
return this.core.profile.GetProfile({ userId });
|
||||
}
|
||||
|
||||
@Patch('avatar')
|
||||
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpdateAvatarDto })
|
||||
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
|
||||
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
|
||||
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiOperation({ summary: 'Обновить профиль', description: 'Обновляет firstName, lastName, bio, age и gender в UserProfile, а displayName синхронизирует с именем и фамилией.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpdateProfileDto })
|
||||
@ApiResponse({ status: 200, description: 'Профиль обновлен' })
|
||||
updateProfile(@Param('userId') userId: string, @Body() dto: UpdateProfileDto) {
|
||||
return this.core.profile.UpdateProfile({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Patch('contacts')
|
||||
@ApiOperation({ summary: 'Обновить контакты', description: 'Обновляет основную и резервную почту/телефон в модели User.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpdateContactsDto })
|
||||
@ApiResponse({ status: 200, description: 'Контакты обновлены' })
|
||||
@ApiResponse({ status: 400, description: 'Переданы некорректные контактные данные' })
|
||||
updateContacts(@Param('userId') userId: string, @Body() dto: UpdateContactsDto) {
|
||||
return this.core.profile.UpdateContacts({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Post('password')
|
||||
@ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта без ранее заданного пароля.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: SetPasswordDto })
|
||||
@ApiResponse({ status: 201, description: 'Пароль установлен' })
|
||||
async setPassword(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: SetPasswordDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.SetPassword({ userId, password: dto.password });
|
||||
}
|
||||
|
||||
@Post('password/otp')
|
||||
@ApiOperation({ summary: 'Отправить OTP для смены пароля' })
|
||||
async sendPasswordVerificationOtp(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: SendPasswordVerificationOtpDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.SendPasswordVerificationOtp({ userId, channel: dto.channel });
|
||||
}
|
||||
|
||||
@Patch('password')
|
||||
@ApiOperation({ summary: 'Сменить пароль с подтверждением личности' })
|
||||
async changePassword(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: ChangePasswordDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.ChangePassword({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Delete('password')
|
||||
@ApiOperation({ summary: 'Удалить пароль с подтверждением личности' })
|
||||
async removePassword(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: PasswordVerificationDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.RemovePassword({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Post('self-delete')
|
||||
@ApiOperation({
|
||||
summary: 'Удалить свой профиль',
|
||||
description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.'
|
||||
})
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 201, description: 'Профиль удалён' })
|
||||
async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.SoftDeleteProfile({ userId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,41 @@ export class SetPasswordDto {
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class SendPasswordVerificationOtpDto {
|
||||
@ApiProperty({ description: 'Канал OTP', enum: ['sms', 'email'] })
|
||||
@IsString({ message: 'Канал должен быть строкой' })
|
||||
channel!: 'sms' | 'email';
|
||||
}
|
||||
|
||||
export class PasswordVerificationDto {
|
||||
@ApiPropertyOptional({ description: 'Текущий пароль' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Текущий пароль должен быть строкой' })
|
||||
currentPassword?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OTP-код из SMS или почты' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'OTP-код должен быть строкой' })
|
||||
otpCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Канал OTP', enum: ['sms', 'email'] })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Канал OTP должен быть строкой' })
|
||||
otpChannel?: 'sms' | 'email';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Код из приложения-аутентификатора' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Код аутентификатора должен быть строкой' })
|
||||
totpCode?: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto extends PasswordVerificationDto {
|
||||
@ApiProperty({ description: 'Новый пароль', minLength: 8 })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class UserIdParamDto {
|
||||
@ApiProperty({ description: 'ID пользователя' })
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
|
||||
@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { OtpInput } from '@/components/ui/otp-input';
|
||||
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
|
||||
@@ -56,8 +57,13 @@ const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable
|
||||
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
||||
};
|
||||
|
||||
const passwordChangeHint =
|
||||
'Чтобы сменить или удалить пароль, подтвердите личность одним из доступных способов ниже.';
|
||||
|
||||
type PasswordVerifyMethod = 'current' | 'sms' | 'email' | 'totp';
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { user, token } = useAuth();
|
||||
const { user, token, refreshProfile } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
@@ -70,6 +76,17 @@ export default function SecurityPage() {
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [passwordVerifyMethod, setPasswordVerifyMethod] = useState<PasswordVerifyMethod>('current');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [passwordOtpCode, setPasswordOtpCode] = useState('');
|
||||
const [passwordOtpSent, setPasswordOtpSent] = useState(false);
|
||||
const [passwordOtpMasked, setPasswordOtpMasked] = useState('');
|
||||
const [passwordTotpCode, setPasswordTotpCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
|
||||
|
||||
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
|
||||
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
if (!user || !token) return;
|
||||
@@ -178,6 +195,108 @@ export default function SecurityPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPasswordVerification() {
|
||||
if (passwordVerifyMethod === 'current') {
|
||||
return { currentPassword: currentPassword.trim() };
|
||||
}
|
||||
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
|
||||
return { otpCode: passwordOtpCode.trim(), otpChannel: passwordVerifyMethod };
|
||||
}
|
||||
return { totpCode: passwordTotpCode.trim() };
|
||||
}
|
||||
|
||||
function isPasswordVerificationReady() {
|
||||
if (passwordVerifyMethod === 'current') return currentPassword.trim().length > 0;
|
||||
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
|
||||
return passwordOtpSent && passwordOtpCode.trim().length === 6;
|
||||
}
|
||||
return passwordTotpCode.trim().length === 6;
|
||||
}
|
||||
|
||||
async function sendPasswordVerificationOtp() {
|
||||
if (!user || !token || (passwordVerifyMethod !== 'sms' && passwordVerifyMethod !== 'email')) return;
|
||||
setPasswordOtpSending(true);
|
||||
try {
|
||||
const response = await apiFetch<{ maskedTarget?: string }>(
|
||||
`/profile/users/${user.id}/password/otp`,
|
||||
{ method: 'POST', body: JSON.stringify({ channel: passwordVerifyMethod }) },
|
||||
token
|
||||
);
|
||||
setPasswordOtpSent(true);
|
||||
setPasswordOtpMasked(response.maskedTarget ?? '');
|
||||
setPasswordOtpCode('');
|
||||
showToast(`Код отправлен${response.maskedTarget ? ` на ${response.maskedTarget}` : ''}`);
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить код') ?? 'Ошибка');
|
||||
} finally {
|
||||
setPasswordOtpSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasswordChange() {
|
||||
if (!user || !token || !newPassword.trim()) {
|
||||
showToast('Укажите новый пароль');
|
||||
return;
|
||||
}
|
||||
if (!isPasswordVerificationReady()) {
|
||||
showToast('Подтвердите личность перед сменой пароля');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/profile/users/${user.id}/password`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ newPassword: newPassword.trim(), ...buildPasswordVerification() })
|
||||
},
|
||||
token
|
||||
);
|
||||
showToast('Пароль изменён');
|
||||
resetPasswordDialog();
|
||||
setDialogMode(null);
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось сменить пароль') ?? 'Ошибка');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasswordRemove() {
|
||||
if (!user || !token) return;
|
||||
if (!isPasswordVerificationReady()) {
|
||||
showToast('Подтвердите личность перед удалением пароля');
|
||||
return;
|
||||
}
|
||||
setIsDialogSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/profile/users/${user.id}/password`,
|
||||
{ method: 'DELETE', body: JSON.stringify(buildPasswordVerification()) },
|
||||
token
|
||||
);
|
||||
showToast('Пароль удалён');
|
||||
resetPasswordDialog();
|
||||
setDialogMode(null);
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось удалить пароль') ?? 'Ошибка');
|
||||
} finally {
|
||||
setIsDialogSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordDialog() {
|
||||
setPasswordVerifyMethod('current');
|
||||
setCurrentPassword('');
|
||||
setPasswordOtpCode('');
|
||||
setPasswordOtpSent(false);
|
||||
setPasswordOtpMasked('');
|
||||
setPasswordTotpCode('');
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
async function submitDialog() {
|
||||
if (!user || !token || !dialogMode) return;
|
||||
setIsDialogSaving(true);
|
||||
@@ -188,6 +307,7 @@ export default function SecurityPage() {
|
||||
} else if (dialogMode === 'password') {
|
||||
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
||||
showToast('Пароль установлен');
|
||||
await refreshProfile();
|
||||
} else if (dialogMode === 'backupEmail') {
|
||||
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
|
||||
showToast('Резервная почта добавлена');
|
||||
@@ -206,6 +326,9 @@ export default function SecurityPage() {
|
||||
|
||||
function openDialog(mode: Exclude<DialogMode, null>) {
|
||||
setDialogValue('');
|
||||
if (mode === 'password') {
|
||||
resetPasswordDialog();
|
||||
}
|
||||
setDialogMode(mode);
|
||||
}
|
||||
|
||||
@@ -280,7 +403,11 @@ export default function SecurityPage() {
|
||||
<KeyRound className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Пароль</p>
|
||||
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
|
||||
<p className="text-sm text-[#667085]">
|
||||
{user?.hasPassword
|
||||
? 'Пароль установлен. Для смены или удаления потребуется подтверждение.'
|
||||
: 'Добавить пароль как способ входа'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
|
||||
</button>
|
||||
@@ -335,6 +462,7 @@ export default function SecurityPage() {
|
||||
setTotpSetup(null);
|
||||
}
|
||||
setTotpCode('');
|
||||
resetPasswordDialog();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
@@ -369,21 +497,133 @@ export default function SecurityPage() {
|
||||
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
{dialogMode === 'password' && user?.hasPassword ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-[#667085]">{passwordChangeHint}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: 'current' as const, label: 'Текущий пароль' },
|
||||
...(hasSmsContact ? [{ id: 'sms' as const, label: 'SMS' }] : []),
|
||||
...(hasEmailContact ? [{ id: 'email' as const, label: 'Почта' }] : []),
|
||||
...(totpEnabled ? [{ id: 'totp' as const, label: 'Аутентификатор' }] : [])
|
||||
].map((method) => (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1.5 text-sm transition',
|
||||
passwordVerifyMethod === method.id
|
||||
? 'bg-[#111827] text-white'
|
||||
: 'bg-[#f4f5f8] text-[#667085] hover:bg-[#eceef4]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setPasswordVerifyMethod(method.id);
|
||||
setPasswordOtpSent(false);
|
||||
setPasswordOtpCode('');
|
||||
setPasswordTotpCode('');
|
||||
setCurrentPassword('');
|
||||
}}
|
||||
>
|
||||
{method.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{passwordVerifyMethod === 'current' ? (
|
||||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<Input
|
||||
className="border-0 bg-transparent focus-visible:ring-0"
|
||||
type="password"
|
||||
placeholder="Текущий пароль"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full rounded-xl"
|
||||
disabled={passwordOtpSending}
|
||||
onClick={() => void sendPasswordVerificationOtp()}
|
||||
>
|
||||
{passwordOtpSending ? 'Отправляем код...' : `Отправить код ${passwordVerifyMethod === 'sms' ? 'в SMS' : 'на почту'}`}
|
||||
</Button>
|
||||
{passwordOtpSent ? (
|
||||
<p className="text-center text-sm text-[#667085]">
|
||||
Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<OtpInput
|
||||
variant="light"
|
||||
value={passwordOtpCode}
|
||||
onChange={setPasswordOtpCode}
|
||||
disabled={!passwordOtpSent || isDialogSaving}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordVerifyMethod === 'totp' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[#667085]">Введите код из приложения-аутентификатора</p>
|
||||
<OtpInput variant="light" value={passwordTotpCode} onChange={setPasswordTotpCode} disabled={isDialogSaving} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<Input
|
||||
className="border-0 bg-transparent focus-visible:ring-0"
|
||||
type="password"
|
||||
placeholder="Новый пароль (мин. 8 символов)"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button
|
||||
className="w-full rounded-xl"
|
||||
disabled={isDialogSaving || newPassword.trim().length < 8 || !isPasswordVerificationReady()}
|
||||
onClick={() => void submitPasswordChange()}
|
||||
>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить новый пароль'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={isDialogSaving || !isPasswordVerificationReady()}
|
||||
onClick={() => void submitPasswordRemove()}
|
||||
>
|
||||
Удалить пароль
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||
<Input
|
||||
className="border-0 bg-transparent focus:ring-0"
|
||||
type={dialogConfig[dialogMode].type}
|
||||
placeholder={dialogConfig[dialogMode].placeholder}
|
||||
value={dialogValue}
|
||||
onChange={(event) => setDialogValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
|
||||
@@ -36,6 +36,14 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin
|
||||
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';
|
||||
if (
|
||||
setting.key === 'SMS_GATEWAY_URL' ||
|
||||
setting.key === 'SMS_GATEWAY_USERNAME' ||
|
||||
setting.key === 'SMS_GATEWAY_PASSWORD' ||
|
||||
setting.key === 'SMS_GATEWAY_MESSAGE_TEMPLATE'
|
||||
) {
|
||||
return provider === 'huawei_modem';
|
||||
}
|
||||
|
||||
const emailProvider = getSettingValue(settings, 'EMAIL_PROVIDER');
|
||||
if (setting.key.startsWith('EMAIL_SMTP_') || setting.key.startsWith('EMAIL_FROM_')) {
|
||||
@@ -180,7 +188,7 @@ export function MessagingSettingsSection({
|
||||
|
||||
<MessagingBlock
|
||||
title="SMS"
|
||||
description="Отправка OTP-кодов по SMS через sms.ru или smsc.ru."
|
||||
description="Отправка OTP-кодов по SMS через sms.ru, smsc.ru или локальный модем Huawei."
|
||||
groupKey="messaging-sms"
|
||||
blockSettings={smsSettings}
|
||||
allSettings={settings}
|
||||
@@ -222,6 +230,7 @@ export function MessagingSettingsSection({
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<PhoneInput
|
||||
variant="light"
|
||||
country={testPhoneCountry}
|
||||
value={testPhone}
|
||||
onCountryChange={setTestPhoneCountry}
|
||||
|
||||
@@ -51,6 +51,13 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : type === 'textarea' ? (
|
||||
<textarea
|
||||
value={setting.value}
|
||||
rows={3}
|
||||
className="min-h-[88px] w-full min-w-[280px] resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface PublicUser {
|
||||
canManageSettings?: boolean;
|
||||
canViewUsers?: boolean;
|
||||
canViewUserDocuments?: boolean;
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select';
|
||||
export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select' | 'textarea';
|
||||
|
||||
export interface SystemSettingMeta {
|
||||
key: string;
|
||||
@@ -78,10 +78,43 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
||||
options: [
|
||||
{ value: 'smsru', label: 'sms.ru' },
|
||||
{ value: 'smsc', label: 'smsc.ru' },
|
||||
{ value: 'huawei_modem', label: 'Huawei E5573 (локальный шлюз)' },
|
||||
{ value: 'console', label: 'Только лог (отладка)' }
|
||||
],
|
||||
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'SMS_GATEWAY_URL',
|
||||
label: 'URL локального SMS-шлюза',
|
||||
group: 'messaging',
|
||||
type: 'text',
|
||||
hint: 'IP или URL модема Huawei HiLink, например http://192.168.8.1',
|
||||
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'SMS_GATEWAY_USERNAME',
|
||||
label: 'Логин модема Huawei',
|
||||
group: 'messaging',
|
||||
type: 'text',
|
||||
hint: 'Обычно admin',
|
||||
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'SMS_GATEWAY_PASSWORD',
|
||||
label: 'Пароль модема Huawei',
|
||||
group: 'messaging',
|
||||
type: 'text',
|
||||
hint: 'Пароль веб-интерфейса модема (требуется для отправки SMS)',
|
||||
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'SMS_GATEWAY_MESSAGE_TEMPLATE',
|
||||
label: 'Шаблон текста SMS (Huawei)',
|
||||
group: 'messaging',
|
||||
type: 'textarea',
|
||||
hint: 'Теги: {{appname}} — название проекта, {{code}} — OTP-код, {{expiry}} — срок действия в минутах',
|
||||
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' } },
|
||||
|
||||
36
apps/sso-core/scripts/trigger-sms-test.mjs
Normal file
36
apps/sso-core/scripts/trigger-sms-test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import grpc from '@grpc/grpc-js';
|
||||
import protoLoader from '@grpc/proto-loader';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const protoPath = path.resolve(__dirname, '../../../shared/proto/security.proto');
|
||||
const grpcUrl = process.env.SSO_CORE_GRPC_URL ?? 'localhost:50051';
|
||||
const target = process.argv[2] ?? '+79591913202';
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(protoPath, {
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
includeDirs: [path.resolve(__dirname, '../../../shared/proto')]
|
||||
});
|
||||
|
||||
const proto = grpc.loadPackageDefinition(packageDefinition);
|
||||
const client = new proto.security.SettingsService(
|
||||
grpcUrl,
|
||||
grpc.credentials.createInsecure()
|
||||
);
|
||||
|
||||
client.TestMessagingDelivery(
|
||||
{ channel: 'sms', target },
|
||||
(error, response) => {
|
||||
if (error) {
|
||||
console.error('gRPC error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Response:', response);
|
||||
process.exit(0);
|
||||
}
|
||||
);
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
573
apps/sso-core/src/infra/sms.service.ts
Normal file
573
apps/sso-core/src/infra/sms.service.ts
Normal 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(
|
||||
Reference in New Issue
Block a user