more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -33,9 +33,10 @@ INTERNAL_WS_URL=http://media-ws:8085
# LDAP в Docker: host network (рекомендуется для AD) или корпоративный DNS
LDAP_USE_HOST_NETWORK=true
# LDAP_DNS_SERVERS=192.168.1.10,192.168.1.11
# IP контроллера домена (обязательно для AD, если имя не резолвится):
# LDAP_EXTRA_HOSTS=DC-1.mvkug.local:192.168.1.10
# LDAP_DNS_SERVERS=192.168.1.10
# LDAP_DNS_SEARCH=mvkug.local
# LDAP_EXTRA_HOSTS=DC-1.mvkug.local:192.168.1.10,DC-2.mvkug.local:192.168.1.11
# Nginx (USE_NGINX_SSL=true только при SSL_TYPE=letsencrypt|selfsigned)
USE_NGINX_SSL=false

View File

@@ -1,12 +1,17 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Headers, Ip, Param, 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 { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
import { resolveAuthorizedPayload } from '../session-auth';
@ApiTags('Биометрия и QR-вход')
@Controller('auth/advanced')
export class AdvancedAuthController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post('webauthn/register/challenge')
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
@@ -28,15 +33,31 @@ export class AdvancedAuthController {
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
@ApiBody({ type: QrSessionDto })
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
createQr(@Body() dto: QrSessionDto) {
return this.core.advancedAuth.CreateQrSession(dto);
createQr(@Body() dto: QrSessionDto, @Ip() ipAddress?: string, @Headers('user-agent') userAgent?: string) {
return this.core.advancedAuth.CreateQrSession({
deviceName: dto.deviceName,
fingerprint: dto.fingerprint,
deviceType: dto.deviceType ?? 'WEB',
ipAddress,
userAgent
});
}
@Get('qr/session/:sessionId')
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/APPROVED/EXPIRED.' })
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
pollQr(@Param('sessionId') sessionId: string) {
return this.core.advancedAuth.PollQrSession({ sessionId });
}
@Post('qr/session/:sessionId/approve')
@ApiBearerAuth()
@ApiOperation({ summary: 'Подтвердить QR-вход', description: 'Подтверждает QR-сессию с мобильного приложения уже авторизованным пользователем.' })
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
@ApiResponse({ status: 201, description: 'QR-сессия подтверждена' })
async approveQr(@Param('sessionId') sessionId: string, @Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return this.core.advancedAuth.ApproveQrSession({ sessionId, userId: payload.sub });
}
}

View File

@@ -1,9 +1,9 @@
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
import { Body, Controller, Get, Headers, Ip, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto';
import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto';
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
@ApiTags('Аутентификация')
@@ -38,8 +38,8 @@ export class AuthController {
@Post('otp/send')
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
@ApiBody({ type: PasswordlessOtpDto })
sendOtp(@Body() dto: PasswordlessOtpDto) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
sendOtp(@Body() dto: PasswordlessOtpDto, @Ip() ipAddress?: string) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress });
}
@Post('otp/verify')
@@ -70,6 +70,20 @@ export class AuthController {
return this.core.auth.VerifyPin(dto);
}
@Post('totp/begin')
@ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' })
@ApiBody({ type: BeginTotpLoginDto })
beginTotpLogin(@Body() dto: BeginTotpLoginDto) {
return this.core.auth.BeginTotpLogin(dto);
}
@Post('totp/verify')
@ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' })
@ApiBody({ type: VerifyTotpLoginDto })
verifyTotpLogin(@Body() dto: VerifyTotpLoginDto) {
return this.core.auth.VerifyTotpLogin(dto);
}
@Post('refresh')
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
@ApiBody({ type: RefreshSessionDto })

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
@@ -91,6 +91,17 @@ export class FamilyController {
return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId }));
}
@Get('groups/:groupId/invite-search')
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
async searchInviteUsers(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Query('q') query: string
) {
const requesterId = await this.auth(authorization);
return firstValueFrom(this.core.family.SearchFamilyInviteUsers({ requesterId, groupId, query: query ?? '' }));
}
@Post('groups/:groupId/invites')
@ApiOperation({ summary: 'Пригласить участника в семью' })
async sendInvite(
@@ -99,7 +110,14 @@ export class FamilyController {
@Body() dto: SendFamilyInviteDto
) {
const requesterId = await this.auth(authorization);
return firstValueFrom(
this.core.family.SendFamilyInvite({
requesterId,
groupId,
target: dto.target,
inviteeUserId: dto.inviteeUserId
})
);
}
@Get('invites')

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
@@ -44,6 +45,49 @@ export class MediaController {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Post('upload')
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Загрузить файл через API', description: 'Принимает файл по uploadToken из upload-url ответа. Обходит прямой доступ браузера к MinIO.' })
@ApiResponse({ status: 201, description: 'Файл загружен' })
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } }))
async uploadObject(
@Headers('x-upload-token') uploadToken: string | undefined,
@UploadedFile() file: { buffer: Buffer; mimetype: string } | undefined
) {
if (!uploadToken) {
throw new BadRequestException('Не передан uploadToken');
}
if (!file) {
throw new BadRequestException('Файл не передан');
}
let payload: { purpose?: string; storageKey?: string; contentType?: string };
try {
payload = await this.jwt.verifyAsync(uploadToken, {
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
issuer: 'id.lendry.ru'
});
} catch {
throw new ForbiddenException('Ссылка загрузки недействительна или истекла');
}
if (payload.purpose !== 'media-upload' || !payload.storageKey) {
throw new ForbiddenException('Ссылка загрузки недействительна');
}
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
await this.getS3Client().send(
new PutObjectCommand({
Bucket: bucket,
Key: payload.storageKey,
Body: file.buffer,
ContentType: file.mimetype || payload.contentType || 'application/octet-stream'
})
);
return { ok: true, storageKey: payload.storageKey };
}
@Post('avatars/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })

View File

@@ -1,20 +1,26 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Headers, Param, 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 { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
import { OptionalPinDto, PinDto, TotpCodeDto, VerifySecurityPinDto } from '../dto/security.dto';
import { resolveAuthorizedPayload } from '../session-auth';
@ApiTags('Безопасность')
@ApiBearerAuth()
@Controller('security')
export class SecurityController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get('users/:userId/devices')
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии. Текущее устройство не отображается.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список устройств получен' })
listDevices(@Param('userId') userId: string) {
return this.core.security.ListActiveDevices({ userId });
async listDevices(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return this.core.security.ListActiveDevices({ userId, exceptSessionId: payload.sessionId });
}
@Get('users/:userId/sessions')
@@ -33,6 +39,36 @@ export class SecurityController {
return this.core.security.ListSignInHistory({ userId });
}
@Get('users/:userId/totp/status')
@ApiOperation({ summary: 'Статус TOTP', description: 'Показывает, включена ли двухфакторная аутентификация через приложение.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
getTotpStatus(@Param('userId') userId: string) {
return this.core.security.GetTotpStatus({ userId });
}
@Post('users/:userId/totp/setup')
@ApiOperation({ summary: 'Настроить TOTP', description: 'Генерирует секрет и otpauth URL для Google Authenticator и аналогов.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
setupTotp(@Param('userId') userId: string) {
return this.core.security.SetupTotp({ userId });
}
@Post('users/:userId/totp/enable')
@ApiOperation({ summary: 'Включить TOTP', description: 'Подтверждает код из приложения и включает двухфакторную аутентификацию.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: TotpCodeDto })
enableTotp(@Param('userId') userId: string, @Body() dto: TotpCodeDto) {
return this.core.security.EnableTotp({ userId, code: dto.code });
}
@Post('users/:userId/totp/disable')
@ApiOperation({ summary: 'Отключить TOTP', description: 'Отключает двухфакторную аутентификацию после проверки кода.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: TotpCodeDto })
disableTotp(@Param('userId') userId: string, @Body() dto: TotpCodeDto) {
return this.core.security.DisableTotp({ userId, code: dto.code });
}
@Post('users/:userId/pin/setup')
@ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@@ -102,10 +138,11 @@ export class SecurityController {
}
@Post('users/:userId/revoke-all-sessions')
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя, кроме текущей.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
revokeAll(@Param('userId') userId: string) {
return this.core.security.RevokeAllSessions({ userId });
@ApiResponse({ status: 201, description: 'Остальные сессии отозваны' })
async revokeAll(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return this.core.security.RevokeAllSessions({ userId, exceptSessionId: payload.sessionId });
}
}

View File

@@ -150,6 +150,41 @@ export class VerifyPinDto {
pin!: string;
}
export class VerifyTotpLoginDto {
@ApiProperty({ description: 'Временный токен после первичной аутентификации' })
@IsString({ message: 'Токен подтверждения должен быть строкой' })
@IsNotEmpty({ message: 'Передайте токен подтверждения' })
totpChallengeToken!: string;
@ApiProperty({ description: '6-значный код из приложения-аутентификатора', example: '123456' })
@IsString({ message: 'Код должен быть строкой' })
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
code!: string;
}
export class BeginTotpLoginDto {
@ApiProperty({ description: 'Почта или телефон пользователя', example: 'user@example.com' })
@IsString({ message: 'Получатель должен быть строкой' })
@IsNotEmpty({ message: 'Укажите почту или телефон' })
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
recipient!: string;
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
fingerprint!: string;
@ApiPropertyOptional({ description: 'Название устройства' })
@IsOptional()
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName?: string;
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
@IsOptional()
@IsString({ message: 'Тип устройства должен быть строкой' })
deviceType?: string;
}
export class RefreshSessionDto {
@ApiProperty({ description: 'Refresh token текущей сессии' })
@IsString({ message: 'Refresh token должен быть строкой' })

View File

@@ -97,6 +97,16 @@ export class QrSessionDto {
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName!: string;
@ApiPropertyOptional({ description: 'Отпечаток устройства браузера' })
@IsOptional()
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
fingerprint?: string;
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
@IsOptional()
@IsString({ message: 'Тип устройства должен быть строкой' })
deviceType?: string;
}
export class CreateFamilyGroupDto {
@@ -127,10 +137,15 @@ export class AddFamilyMemberDto {
}
export class SendFamilyInviteDto {
@ApiProperty({ description: 'Email, телефон или логин приглашаемого' })
@ApiPropertyOptional({ description: 'Email, телефон или логин (legacy)' })
@IsOptional()
@IsString({ message: 'Укажите контакт приглашаемого' })
@IsNotEmpty({ message: 'Укажите контакт приглашаемого' })
target!: string;
target?: string;
@ApiPropertyOptional({ description: 'ID пользователя из результатов поиска' })
@IsOptional()
@IsString({ message: 'ID пользователя должен быть строкой' })
inviteeUserId?: string;
}
export class RespondFamilyInviteDto {

View File

@@ -40,6 +40,11 @@ export class UpdateProfileDto {
@IsOptional()
@IsString({ message: 'Пол должен быть строкой' })
gender?: string;
@ApiPropertyOptional({ description: 'Дата рождения в формате YYYY-MM-DD', example: '1990-05-15' })
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'Укажите дату рождения в формате ГГГГ-ММ-ДД' })
birthDate?: string;
}
export class UpdateContactsDto {

View File

@@ -23,3 +23,11 @@ export class VerifySecurityPinDto extends PinDto {
@IsString({ message: 'ID сессии должен быть строкой' })
sessionId!: string;
}
export class TotpCodeDto {
@ApiProperty({ description: '6-значный код из приложения-аутентификатора', example: '123456' })
@IsString({ message: 'Код должен быть строкой' })
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
code!: string;
}

View File

@@ -17,9 +17,11 @@ export const apiReference: ApiTagGroup[] = [
endpoints: [
{ method: 'POST', path: '/auth/register', summary: 'Регистрация пользователя', description: 'Первый пользователь получает isSuperAdmin.' },
{ method: 'POST', path: '/auth/login', summary: 'Вход по почте, телефону или логину' },
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)' },
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа' },
{ method: 'POST', path: '/auth/otp/verify', summary: роверить OTP' },
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)', description: 'Возвращает isTotpEnabled, otpChannels, methods.' },
{ method: 'POST', path: '/auth/totp/begin', summary: 'Начать вход по TOTP', description: 'Challenge для Google Authenticator вместо SMS/email OTP.' },
{ method: 'POST', path: '/auth/totp/verify', summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после кода из приложения-аутентификатора.' },
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа', description: 'Альтернатива TOTP; channel: email | phone | backupEmail | backupPhone.' },
{ method: 'POST', path: '/auth/otp/verify', summary: 'Проверить OTP', description: 'Завершает вход по SMS/email без повторного TOTP.' },
{ method: 'POST', path: '/auth/login/password', summary: 'Войти по паролю' },
{ method: 'POST', path: '/auth/ldap/login', summary: 'Войти через LDAP/LDAPS' },
{ method: 'POST', path: '/auth/pin/verify', summary: 'Подтвердить PIN-код' },
@@ -52,6 +54,10 @@ export const apiReference: ApiTagGroup[] = [
endpoints: [
{ method: 'GET', path: '/security/users/{userId}/devices', summary: 'Активные устройства', auth: true },
{ method: 'GET', path: '/security/users/{userId}/sessions', summary: 'Активные сессии', auth: true },
{ method: 'GET', path: '/security/users/{userId}/totp/status', summary: 'Статус TOTP', auth: true },
{ method: 'POST', path: '/security/users/{userId}/totp/setup', summary: 'Настроить TOTP (QR + секрет)', auth: true },
{ method: 'POST', path: '/security/users/{userId}/totp/enable', summary: 'Включить TOTP', auth: true },
{ method: 'POST', path: '/security/users/{userId}/totp/disable', summary: 'Отключить TOTP', auth: true },
{ method: 'POST', path: '/security/users/{userId}/pin/setup', summary: 'Настроить PIN', auth: true },
{ method: 'POST', path: '/security/users/{userId}/revoke-all-sessions', summary: 'Выйти везде', auth: true }
]

View File

@@ -571,7 +571,7 @@ docker compose restart sso-core`
{
slug: 'authentication',
title: 'Аутентификация',
description: 'Способы входа: пароль, OTP, LDAP, PIN и refresh-сессии.',
description: 'Способы входа: TOTP (Google Authenticator), OTP, пароль, LDAP, PIN и refresh-сессии.',
sections: [
{
id: 'flows',
@@ -581,15 +581,147 @@ docker compose restart sso-core`
type: 'table',
headers: ['Сценарий', 'Endpoint', 'Описание'],
rows: [
['Identifier-first', 'POST /auth/identify', 'Проверка существования пользователя и способа входа'],
['Passwordless OTP', 'POST /auth/otp/send + verify', '6-значный код на почту/телефон'],
['Пароль', 'POST /auth/login/password', 'Логин + пароль или tempAuthToken после OTP'],
['Identifier-first', 'POST /auth/identify', 'Проверка пользователя, isTotpEnabled, otpChannels и альтернативных способов'],
['TOTP (основной)', 'POST /auth/totp/begin + verify', 'Вход через Google Authenticator, если аутентификатор подключён'],
['Passwordless OTP (альтернатива)', 'POST /auth/otp/send + verify', '6-значный код на почту/телефон вместо TOTP'],
['Пароль', 'POST /auth/login/password', 'Альтернативный вход по паролю'],
['LDAP/LDAPS', 'POST /auth/ldap/login', 'Корпоративный вход (требует LDAP_ENABLED)'],
['PIN', 'POST /auth/pin/verify', 'Разблокировка сессии после входа с PIN']
]
}
]
},
{
id: 'totp-login',
title: 'Вход через приложение-аутентификатор (TOTP)',
blocks: [
{
type: 'paragraph',
text: 'Если пользователь подключил Google Authenticator (или аналог) в разделе «Безопасность», код из приложения становится основным способом входа вместо SMS/email OTP. SMS и email используются только как альтернатива через «Другой способ входа».'
},
{
type: 'list',
items: [
'1. POST /auth/identify с почтой или телефоном — в ответе isTotpEnabled: true и otpChannels (доступные каналы для альтернативного OTP).',
'2. POST /auth/totp/begin — создаёт challenge (totpChallengeToken, TTL 5 минут). SMS/email на этом шаге не отправляются.',
'3. Пользователь вводит 6-значный код из приложения-аутентификатора.',
'4. POST /auth/totp/verify с totpChallengeToken и code — выдаётся JWT и refresh token (далее при необходимости PIN).'
]
},
{
type: 'callout',
variant: 'tip',
title: 'TOTP и SMS/email — альтернативы',
text: 'Код из аутентификатора и код из SMS/email не комбинируются: это два разных способа пройти один и тот же шаг входа. После успешного OTP verify TOTP повторно не запрашивается.'
},
{
type: 'table',
headers: ['Ситуация', 'Поведение UI / API'],
rows: [
['Аутентификатор подключён', 'После identify сразу экран TOTP (totp/begin), без автоматической отправки SMS/email'],
['Альтернатива: код на почту/телефон', 'POST /auth/otp/send с channel: email или phone, затем POST /auth/otp/verify'],
['Привязаны и почта, и телефон', 'Пользователь выбирает канал (otpChannels) перед отправкой OTP'],
['Только почта или только телефон', 'OTP отправляется сразу на единственный доступный канал'],
['Альтернатива: пароль', 'POST /auth/login/password — без дополнительного TOTP'],
['Включён PIN', 'После TOTP или OTP — POST /auth/pin/verify для полной сессии']
]
},
{
type: 'code',
title: 'POST /auth/identify — фрагмент ответа',
language: 'json',
code: `{
"exists": true,
"hasPassword": true,
"isPinEnabled": false,
"isTotpEnabled": true,
"otpChannels": [
{ "channel": "email", "masked": "u***@example.com" },
{ "channel": "phone", "masked": "+7********42" }
],
"methods": [
{ "kind": "password", "channel": "password", "masked": "Пароль" }
]
}`
},
{
type: 'code',
title: 'Начать вход по TOTP',
language: 'bash',
code: `curl -X POST http://localhost:3000/auth/totp/begin \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"fingerprint": "device-fingerprint-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}'
# Ответ: { "totpChallengeToken": "eyJ..." }`
},
{
type: 'code',
title: 'Подтвердить код аутентификатора',
language: 'bash',
code: `curl -X POST http://localhost:3000/auth/totp/verify \\
-H "Content-Type: application/json" \\
-d '{
"totpChallengeToken": "eyJ...",
"code": "123456"
}'
# Ответ: AuthTokens (accessToken, refreshToken, sessionId, pinVerified, user)`
},
{
type: 'code',
title: 'Альтернатива: OTP на выбранный канал',
language: 'bash',
code: `# Отправить код (channel: email | phone | backupEmail | backupPhone)
curl -X POST http://localhost:3000/auth/otp/send \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"channel": "phone"
}'
# Подтвердить OTP — сразу выдаёт сессию, без TOTP
curl -X POST http://localhost:3000/auth/otp/verify \\
-H "Content-Type: application/json" \\
-d '{
"recipient": "user@example.com",
"code": "654321",
"fingerprint": "device-fingerprint-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}'`
}
]
},
{
id: 'totp-setup',
title: 'Подключение аутентификатора',
blocks: [
{
type: 'paragraph',
text: 'Настройка выполняется в личном кабинете: Безопасность → Дополнительная защита → Подключить аутентификатор. Название в Google Authenticator берётся из SystemSetting PROJECT_NAME (не захардкожено).'
},
{
type: 'list',
items: [
'GET /security/users/{userId}/totp/status — проверка, включён ли TOTP',
'POST /security/users/{userId}/totp/setup — QR-код (otpauthUrl) и секретный ключ',
'POST /security/users/{userId}/totp/enable — подтверждение первого кода и активация',
'POST /security/users/{userId}/totp/disable — отключение с проверкой текущего кода'
]
},
{
type: 'callout',
variant: 'info',
title: 'Повторная настройка',
text: 'Если настройка начата, но не завершена, повторный вызов setup возвращает тот же секрет — не нужно сканировать новый QR, пока не истёк незавершённый challenge.'
}
]
},
{
id: 'login-example',
title: 'Примеры входа по паролю',
@@ -709,6 +841,24 @@ docker compose restart sso-core`
title: 'Сессии и PIN',
description: 'Управление устройствами, PIN-блокировка и отзыв сессий.',
sections: [
{
id: 'totp',
title: 'TOTP (Google Authenticator)',
blocks: [
{
type: 'paragraph',
text: 'Двухфакторная аутентификация через TOTP заменяет SMS/email OTP при входе. Управление устройствами и отзыв сессий — через те же endpoints, что описаны ниже.'
},
{
type: 'list',
items: [
'GET /security/users/{userId}/totp/status',
'POST /security/users/{userId}/totp/setup | enable | disable',
'POST /auth/totp/begin и POST /auth/totp/verify — вход с аутентификатором'
]
}
]
},
{
id: 'pin',
title: 'PIN-код',

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
import { AdminShell } from '@/components/id/admin-shell';
import { useAuth } from '@/components/id/auth-provider';
@@ -78,6 +78,23 @@ export default function AdminUsersPage() {
}
}
async function handleUnsuspend(user: AdminUser) {
if (!token) return;
setActionLoading(true);
try {
await apiFetch(`/admin/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ACTIVE' })
}, token);
showToast('Пользователь разблокирован');
await loadUsers();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось разблокировать пользователя');
} finally {
setActionLoading(false);
}
}
async function handleResetPassword() {
if (!token || !selectedUser || password.length < 8) return;
setActionLoading(true);
@@ -212,6 +229,9 @@ export default function AdminUsersPage() {
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
<Ban className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" aria-label="Разблокировать" disabled={actionLoading || user.status !== 'SUSPENDED'} onClick={() => void handleUnsuspend(user)}>
<UserCheck className="h-4 w-4" />
</Button>
</>
) : null}
{currentUser?.canViewUserDocuments ? (

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,15 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { PhoneInput, parseE164Phone, phoneCountries, toE164 } from '@/components/ui/phone-input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
getDocumentType,
indexDocumentsByType,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api';
function Row({
icon: Icon,
@@ -72,6 +74,11 @@ export default function DataPage() {
const [backupEmail, setBackupEmail] = useState('');
const [phone, setPhone] = useState('');
const [backupPhone, setBackupPhone] = useState('');
const [birthDate, setBirthDate] = useState<string | undefined>();
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
const [phoneDigits, setPhoneDigits] = useState('');
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
const [documents, setDocuments] = useState<UserDocument[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
@@ -94,10 +101,25 @@ export default function DataPage() {
setDisplayName(user.displayName);
setEmail(user.email ?? '');
setBackupEmail(user.backupEmail ?? '');
setPhone(user.phone ?? '');
setBackupPhone(user.backupPhone ?? '');
const parsedPhone = parseE164Phone(user.phone ?? '');
setPhoneCountry(parsedPhone.country);
setPhoneDigits(parsedPhone.digits);
setPhone(parsedPhone.digits ? toE164(parsedPhone.country, parsedPhone.digits) : '');
const parsedBackupPhone = parseE164Phone(user.backupPhone ?? '');
setBackupPhoneCountry(parsedBackupPhone.country);
setBackupPhoneDigits(parsedBackupPhone.digits);
setBackupPhone(parsedBackupPhone.digits ? toE164(parsedBackupPhone.country, parsedBackupPhone.digits) : '');
}, [user]);
useEffect(() => {
if (!user || !token || isPinLocked) return;
void apiFetch<UserProfileResponse>(`/profile/users/${user.id}`, {}, token)
.then((profile) => {
if (profile.birthDate) setBirthDate(profile.birthDate);
})
.catch(() => undefined);
}, [isPinLocked, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]);
@@ -116,14 +138,20 @@ export default function DataPage() {
try {
await apiFetch(`/profile/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
body: JSON.stringify({
firstName: firstName || undefined,
lastName: rest.join(' ') || undefined,
birthDate: birthDate || undefined
})
}, token);
const contacts: Record<string, string> = {};
const nextPhone = phoneDigits ? toE164(phoneCountry, phoneDigits) : '';
const nextBackupPhone = backupPhoneDigits ? toE164(backupPhoneCountry, backupPhoneDigits) : '';
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
if (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
if (Object.keys(contacts).length > 0) {
await apiFetch(`/profile/users/${user.id}/contacts`, {
@@ -177,11 +205,31 @@ export default function DataPage() {
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
<Input placeholder="Дата рождения" />
<DatePicker value={birthDate} onChange={setBirthDate} />
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
<PhoneInput
variant="light"
country={phoneCountry}
value={phoneDigits}
onCountryChange={setPhoneCountry}
onValueChange={(digits) => {
setPhoneDigits(digits);
setPhone(digits ? toE164(phoneCountry, digits) : '');
}}
required={false}
/>
<PhoneInput
variant="light"
country={backupPhoneCountry}
value={backupPhoneDigits}
onCountryChange={setBackupPhoneCountry}
onValueChange={(digits) => {
setBackupPhoneDigits(digits);
setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : '');
}}
required={false}
/>
</div>
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "leaflet/dist/leaflet.css";
@import "react-day-picker/style.css";
:root {
--background: #ffffff;
@@ -23,6 +24,11 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
.rdp-root {
--rdp-accent-color: #111827;
--rdp-accent-background-color: #f4f5f8;
}
@layer base {
a {
color: inherit;

View File

@@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ChevronRight,
Clock3,
@@ -24,7 +23,9 @@ import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth';
function formatDate(value: string) {
@@ -46,9 +47,9 @@ const deviceIcons: Record<string, LucideIcon> = {
OTHER: Laptop
};
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { title: string; placeholder: string; type: string }> = {
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
@@ -56,8 +57,7 @@ const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placehold
};
export default function SecurityPage() {
const router = useRouter();
const { user, token, logout } = useAuth();
const { user, token } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]);
@@ -67,17 +67,22 @@ export default function SecurityPage() {
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [dialogValue, setDialogValue] = useState('');
const [isDialogSaving, setIsDialogSaving] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
const [totpCode, setTotpCode] = useState('');
const loadSecurity = useCallback(async () => {
if (!user || !token) return;
setIsSecurityLoading(true);
try {
const [devicesResponse, historyResponse] = await Promise.all([
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
]);
setDevices(devicesResponse.devices ?? []);
setHistory(historyResponse.events ?? []);
setTotpEnabled(Boolean(totpResponse.isEnabled));
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
} finally {
@@ -106,8 +111,8 @@ export default function SecurityPage() {
setIsRevoking(true);
try {
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
showToast('Все сессии завершены');
logout();
showToast('Выход выполнен на всех остальных устройствах');
await loadSecurity();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
} finally {
@@ -115,6 +120,64 @@ export default function SecurityPage() {
}
}
async function openTotpDialog() {
if (!user || !token) return;
if (totpEnabled) {
setTotpCode('');
setDialogMode('totpDisable');
return;
}
if (totpSetup) {
setTotpCode('');
setDialogMode('totpSetup');
return;
}
setIsDialogSaving(true);
try {
const setup = await apiFetch<TotpSetupResponse>(`/security/users/${user.id}/totp/setup`, { method: 'POST' }, token);
setTotpSetup(setup);
setTotpCode('');
setDialogMode('totpSetup');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось начать настройку аутентификатора');
} finally {
setIsDialogSaving(false);
}
}
async function submitTotpSetup(code: string) {
if (!user || !token) return;
setIsDialogSaving(true);
try {
await apiFetch(`/security/users/${user.id}/totp/enable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
setTotpEnabled(true);
setDialogMode(null);
setTotpSetup(null);
setTotpCode('');
showToast('Приложение-аутентификатор подключено');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось подтвердить код');
} finally {
setIsDialogSaving(false);
}
}
async function submitTotpDisable(code: string) {
if (!user || !token) return;
setIsDialogSaving(true);
try {
await apiFetch(`/security/users/${user.id}/totp/disable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
setTotpEnabled(false);
setDialogMode(null);
setTotpCode('');
showToast('Приложение-аутентификатор отключено');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отключить аутентификатор');
} finally {
setIsDialogSaving(false);
}
}
async function submitDialog() {
if (!user || !token || !dialogMode) return;
setIsDialogSaving(true);
@@ -183,8 +246,25 @@ export default function SecurityPage() {
<LogOut className="h-6 w-6" />
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
</button>
<p className="text-sm text-[#667085]">Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства через меню профиля.</p>
</div>
<section className="mt-10">
<h2 className="text-2xl font-medium">Дополнительная защита</h2>
<div className="mt-4 space-y-2">
<button type="button" onClick={() => void openTotpDialog()} disabled={isDialogSaving} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6] disabled:opacity-60">
<ShieldCheck className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Приложение-аутентификатор</p>
<p className="text-sm text-[#667085]">
{totpEnabled ? 'Google Authenticator и аналоги подключены' : 'Подключите OTP-коды через Google Authenticator'}
</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
</div>
</section>
<section className="mt-10">
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
<div className="mt-4 space-y-2">
@@ -248,9 +328,45 @@ export default function SecurityPage() {
</div>
</section>
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
<Dialog open={dialogMode !== null} onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
if (dialogMode !== 'totpSetup' || totpEnabled) {
setTotpSetup(null);
}
setTotpCode('');
}
}}>
<DialogContent>
{dialogMode ? (
{dialogMode === 'totpSetup' && totpSetup ? (
<>
<DialogHeader>
<DialogTitle>Подключить аутентификатор</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.</p>
<div className="flex justify-center rounded-2xl bg-white p-4">
<QRCodeSVG value={totpSetup.otpauthUrl} size={180} />
</div>
<p className="break-all rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm text-[#667085]">
Ключ: <span className="font-mono text-[#111827]">{totpSetup.secret}</span>
</p>
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpSetup(code)} disabled={isDialogSaving} />
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
</>
) : null}
{dialogMode === 'totpDisable' ? (
<>
<DialogHeader>
<DialogTitle>Отключить аутентификатор</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">Введите код из приложения, чтобы отключить двухфакторную аутентификацию.</p>
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpDisable(code)} disabled={isDialogSaving} />
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
</>
) : null}
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
<>
<DialogHeader>
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>

View File

@@ -17,7 +17,7 @@ import {
withDocumentPhotos,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
@@ -266,12 +266,7 @@ export function DocumentFormDialog({
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
await uploadMediaObject(presigned, file, file.type);
const nextPhotos = [...currentPhotos, presigned.storageKey];
photoStorageKeysRef.current = nextPhotos;

View File

@@ -31,14 +31,17 @@ import {
ChatRoom,
createChatRoom,
FamilyGroup,
FamilyInviteCandidate,
fetchChatMessages,
fetchChatRooms,
fetchFamilyGroup,
getApiErrorMessage,
searchFamilyInviteUsers,
sendChatMessage,
sendFamilyInvite,
setChatRoomMuted,
updateFamilyGroup,
uploadMediaObject,
voteChatPoll
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -63,6 +66,8 @@ const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏'
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
interface LoadedChatMedia {
@@ -108,7 +113,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [inviteOpen, setInviteOpen] = useState(false);
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const [pollOpen, setPollOpen] = useState(false);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteQuery, setInviteQuery] = useState('');
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [inviteSearching, setInviteSearching] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
@@ -300,7 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
await uploadMediaObject(presigned, file, file.type);
await apiFetch(`/media/families/${groupId}/avatar/confirm`, {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
@@ -313,17 +321,38 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
async function submitInvite() {
if (!token || !inviteTarget.trim()) return;
if (!token || !selectedInviteUser) return;
try {
await sendFamilyInvite(groupId, inviteTarget.trim(), token);
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
setInviteOpen(false);
setInviteTarget('');
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
showToast('Приглашение отправлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
}
}
useEffect(() => {
if (!inviteOpen || !token || selectedInviteUser) {
return;
}
const query = inviteQuery.trim();
if (query.length < 2) {
setInviteResults([]);
return;
}
const timer = window.setTimeout(() => {
setInviteSearching(true);
void searchFamilyInviteUsers(groupId, query, token)
.then((response) => setInviteResults(response.users ?? []))
.catch(() => setInviteResults([]))
.finally(() => setInviteSearching(false));
}, 300);
return () => window.clearTimeout(timer);
}, [groupId, inviteOpen, inviteQuery, selectedInviteUser, token]);
async function submitCreateRoom() {
if (!token || !newRoomName.trim()) return;
try {
@@ -377,11 +406,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
},
token
);
await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
await uploadMediaObject(presigned, file, contentType);
const message = await sendChatMessage(
activeRoomId,
{
@@ -747,11 +772,72 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<Dialog open={inviteOpen} onOpenChange={(open) => {
setInviteOpen(open);
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
}
}}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Пригласить в семью</DialogTitle></DialogHeader>
<Input placeholder="Email, телефон или логин" value={inviteTarget} onChange={(event) => setInviteTarget(event.target.value)} />
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitInvite()}>Отправить приглашение</Button>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
<Avatar className="h-9 w-9">
<AvatarFallback>{candidate.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser} onClick={() => void submitInvite()}>
Отправить приглашение
</Button>
</DialogContent>
</Dialog>

View File

@@ -39,7 +39,10 @@ interface AuthContextValue {
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
beginTotpLogin: (recipient: string) => Promise<string>;
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
applyLoginAuth: (auth: AuthTokens) => AuthTokens;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
@@ -381,6 +384,50 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[saveSession]
);
const beginTotpLogin = React.useCallback(async (recipient: string) => {
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
return response.totpChallengeToken;
}, []);
const verifyTotpLogin = React.useCallback(
async (totpChallengeToken: string, code: string) => {
const auth = await apiFetch<AuthTokens>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ totpChallengeToken, code })
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const applyLoginAuth = React.useCallback(
(auth: AuthTokens) => {
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
activatePinLock(auth.sessionId);
}
return auth;
},
[activatePinLock, saveSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
@@ -452,7 +499,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
beginTotpLogin,
verifyTotpLogin,
completePin,
applyLoginAuth,
register,
refreshProfile,
logout

View File

@@ -6,12 +6,13 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/id/toast-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { apiFetch, getApiErrorMessage } from '@/lib/api';
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
uploadToken?: string;
}
export function AvatarUpload({
@@ -53,15 +54,7 @@ export function AvatarUpload({
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) {
throw new Error('Не удалось загрузить файл в хранилище');
}
await uploadMediaObject(presigned, file, file.type);
await apiFetch('/media/avatars/confirm', {
method: 'POST',

View File

@@ -28,6 +28,8 @@ const buttonVariants = cva(
}
);
export { buttonVariants };
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -0,0 +1,79 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker, getDefaultClassNames, type DayButtonProps } from 'react-day-picker';
import { ru } from 'react-day-picker/locale';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
function CalendarDayButton({ className, day, modifiers, ...props }: DayButtonProps) {
return (
<button
type="button"
className={cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
modifiers.selected && 'bg-[#111827] text-white hover:bg-[#111827] hover:text-white',
modifiers.today && !modifiers.selected && 'bg-[#f4f5f8] text-[#111827]',
modifiers.outside && 'text-[#a8adbc] opacity-50',
modifiers.disabled && 'text-[#a8adbc] opacity-50',
className
)}
{...props}
/>
);
}
export function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
const defaults = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={ru}
className={cn('p-3', className)}
classNames={{
root: cn('rdp-root', defaults.root),
months: cn('flex flex-col gap-4 sm:flex-row', defaults.months),
month: cn('flex flex-col gap-4', defaults.month),
month_caption: cn('flex justify-center pt-1 relative items-center', defaults.month_caption),
caption_label: cn('text-sm font-medium capitalize', defaults.caption_label),
dropdowns: cn('flex items-center justify-center gap-2', defaults.dropdowns),
dropdown_root: cn('relative inline-flex items-center gap-1', defaults.dropdown_root),
dropdown: defaults.dropdown,
months_dropdown: defaults.months_dropdown,
years_dropdown: defaults.years_dropdown,
nav: cn('flex items-center gap-1', defaults.nav),
button_previous: cn(
buttonVariants({ variant: 'ghost' }),
'absolute left-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
defaults.button_previous
),
button_next: cn(
buttonVariants({ variant: 'ghost' }),
'absolute right-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
defaults.button_next
),
month_grid: cn('w-full border-collapse space-y-1', defaults.month_grid),
weekdays: cn('flex', defaults.weekdays),
weekday: cn('text-[#667085] rounded-md w-9 font-normal text-[0.8rem]', defaults.weekday),
week: cn('flex w-full mt-2', defaults.week),
day: cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', defaults.day),
day_button: cn('h-9 w-9 p-0 font-normal aria-selected:opacity-100', defaults.day_button),
selected: cn('bg-[#111827] text-white hover:bg-[#111827] hover:text-white focus:bg-[#111827] focus:text-white', defaults.selected),
today: cn('bg-[#f4f5f8] text-[#111827]', defaults.today),
outside: cn('text-[#a8adbc] opacity-50', defaults.outside),
disabled: cn('text-[#a8adbc] opacity-50', defaults.disabled),
hidden: cn('invisible', defaults.hidden),
...classNames
}}
components={{
Chevron: ({ orientation }) => (orientation === 'left' ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />),
DayButton: CalendarDayButton
}}
{...props}
/>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { format, parseISO } from 'date-fns';
import { ru } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function DatePicker({
value,
onChange,
placeholder = 'Дата рождения',
className,
disabled
}: {
value?: string;
onChange: (value: string | undefined) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}) {
const selected = value ? parseISO(value) : undefined;
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
disabled={disabled}
className={cn(
'h-11 w-full justify-start rounded-2xl border border-[#eceef4] bg-white px-4 text-left font-normal text-[#111827] hover:bg-white',
!value && 'text-[#667085]',
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4 text-[#667085]" />
{value ? format(parseISO(value), 'd MMMM yyyy', { locale: ru }) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={(date) => onChange(date ? format(date, 'yyyy-MM-dd') : undefined)}
disabled={(date) => date > new Date()}
defaultMonth={selected ?? new Date(1990, 0, 1)}
captionLayout="dropdown"
hideNavigation
startMonth={new Date(1920, 0)}
endMonth={new Date()}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -3,16 +3,35 @@
import { useRef } from 'react';
import { cn } from '@/lib/utils';
const otpThemes = {
dark: {
box: 'border-[#555762] bg-transparent text-white focus:border-[#7b7f8f] focus:ring-white/10'
},
light: {
box: 'border-[#eceef4] bg-white text-[#111827] focus:border-[#c7cad6] focus:ring-[#eceef4]'
}
} as const;
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
length?: number;
disabled?: boolean;
variant?: keyof typeof otpThemes;
className?: string;
}
export function OtpInput({ value, onChange, onComplete, length = 6, disabled = false, className }: OtpInputProps) {
export function OtpInput({
value,
onChange,
onComplete,
length = 6,
disabled = false,
variant = 'dark',
className
}: OtpInputProps) {
const theme = otpThemes[variant];
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
@@ -83,7 +102,10 @@ export function OtpInput({ value, onChange, onComplete, length = 6, disabled = f
onChange={(event) => handleChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
className="h-14 w-full rounded-2xl border border-[#555762] bg-transparent text-center text-2xl font-semibold text-white outline-none transition focus:border-[#7b7f8f] focus:ring-4 focus:ring-white/10 disabled:opacity-50"
className={cn(
'h-14 w-full rounded-2xl border text-center text-2xl font-semibold outline-none transition focus:ring-4 disabled:opacity-50',
theme.box
)}
/>
))}
</div>

View File

@@ -65,34 +65,71 @@ export function toE164(country: CountryOption, digits: string) {
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
}
export function parseE164Phone(e164: string): { country: CountryOption; digits: string } {
if (!e164) {
return { country: phoneCountries[0], digits: '' };
}
const sorted = [...phoneCountries].sort((a, b) => b.code.length - a.code.length);
for (const country of sorted) {
if (e164.startsWith(country.code)) {
return { country, digits: e164.slice(country.code.length).replace(/\D/g, '') };
}
}
return { country: phoneCountries[0], digits: e164.replace(/\D/g, '') };
}
const phoneThemes = {
dark: {
shell: 'border-[#555762] bg-transparent text-white focus-within:border-[#7b7f8f] focus-within:ring-white/10',
trigger: 'text-white hover:bg-[#2a2c36]',
chevron: 'text-[#8f92a0]',
input: 'text-white placeholder:text-[#8f92a0]',
clear: 'text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white'
},
light: {
shell: 'border-[#eceef4] bg-white text-[#111827] focus-within:border-[#c7cad6] focus-within:ring-[#eceef4]',
trigger: 'text-[#111827] hover:bg-[#f4f5f8]',
chevron: 'text-[#667085]',
input: 'text-[#111827] placeholder:text-[#667085]',
clear: 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#111827]'
}
} as const;
export function PhoneInput({
country,
value,
onCountryChange,
onValueChange,
className
className,
variant = 'dark',
required = true
}: {
country: CountryOption;
value: string;
onCountryChange: (country: CountryOption) => void;
onValueChange: (digits: string) => void;
className?: string;
variant?: keyof typeof phoneThemes;
required?: boolean;
}) {
const maskedValue = formatPhoneNumber(value, country);
const theme = phoneThemes[variant];
return (
<div
className={cn(
'flex h-[58px] w-full items-center rounded-2xl border border-[#555762] bg-transparent text-white transition focus-within:border-[#7b7f8f] focus-within:ring-4 focus-within:ring-white/10',
'flex h-[58px] w-full items-center rounded-2xl border transition focus-within:ring-4',
variant === 'light' ? 'h-11' : '',
theme.shell,
className
)}
>
<Popover>
<PopoverTrigger asChild>
<button type="button" className="flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 text-white outline-none hover:bg-[#2a2c36]" aria-label="Выбрать страну">
<button type="button" className={cn('flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 outline-none', theme.trigger)} aria-label="Выбрать страну">
<FlagIcon src={country.flagSrc} />
<span className="text-base font-semibold">{country.code}</span>
<ChevronDown className="h-4 w-4 text-[#8f92a0]" />
<ChevronDown className={cn('h-4 w-4', theme.chevron)} />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-1">
@@ -122,16 +159,16 @@ export function PhoneInput({
</Popover>
<input
className="h-full min-w-0 flex-1 bg-transparent px-1 text-lg text-white outline-none placeholder:text-[#8f92a0]"
className={cn('h-full min-w-0 flex-1 bg-transparent px-1 text-lg outline-none', variant === 'light' ? 'text-base' : '', theme.input)}
inputMode="tel"
placeholder={country.code === '+375' ? '(29) 123-45-67' : '(999) 123-45-67'}
value={maskedValue}
onChange={(event) => onValueChange(event.target.value.replace(/\D/g, '').slice(0, country.maxDigits))}
required
required={required}
/>
{value ? (
<button type="button" className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white" onClick={() => onValueChange('')} aria-label="Очистить телефон">
<button type="button" className={cn('mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full', theme.clear)} onClick={() => onValueChange('')} aria-label="Очистить телефон">
<X className="h-4 w-4" />
</button>
) : null}

View File

@@ -129,14 +129,120 @@ export interface AuthTokens {
pinVerified: boolean;
user: PublicUser;
sessionId: string;
requiresTotp?: boolean;
totpChallengeToken?: string;
}
export interface UserProfileResponse {
birthDate?: string | null;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName?: string;
firstName?: string | null;
lastName?: string | null;
}
export interface QrLoginSession {
sessionId: string;
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
expiresAt: string;
qrPayload?: string;
auth?: AuthTokens & { user?: PublicUser };
}
export async function createQrLoginSession(deviceName: string) {
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
method: 'POST',
body: JSON.stringify({
deviceName,
fingerprint: getDeviceFingerprint(),
deviceType: 'WEB'
})
});
}
export async function pollQrLoginSession(sessionId: string) {
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
}
export interface TotpSetupResponse {
secret: string;
otpauthUrl: string;
}
export interface TotpStatusResponse {
isEnabled: boolean;
}
export interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
export async function uploadMediaObject(
presigned: PresignedUploadResponse,
file: Blob,
contentType: string
) {
if (presigned.uploadToken) {
const apiBase = getApiUrl().replace(/\/$/, '');
const formData = new FormData();
formData.append('file', file, file instanceof File ? file.name : 'upload.bin');
const response = await fetch(`${apiBase}/media/upload`, {
method: 'POST',
headers: { 'X-Upload-Token': presigned.uploadToken },
body: formData
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
return;
}
const response = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
}
export interface FamilyInviteCandidate {
id: string;
displayName: string;
email?: string | null;
phone?: string | null;
username?: string | null;
hasAvatar?: boolean;
}
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
return apiFetch<{ users?: FamilyInviteCandidate[] }>(
`/family/groups/${groupId}/invite-search?q=${encodeURIComponent(query)}`,
{},
token
);
}
export interface PasswordlessAuthResponse {
requiresPassword: boolean;
requiresTotp?: boolean;
totpChallengeToken?: string;
tempAuthToken?: string;
auth?: AuthTokens;
}
export interface OtpChannel {
channel: 'email' | 'phone' | 'backupEmail' | 'backupPhone' | string;
masked: string;
}
export interface LoginMethod {
kind: 'password' | 'otp';
channel: string;
@@ -147,9 +253,15 @@ export interface IdentifyResponse {
exists: boolean;
hasPassword: boolean;
isPinEnabled: boolean;
isTotpEnabled?: boolean;
otpChannels?: OtpChannel[];
methods?: LoginMethod[];
}
export interface BeginTotpLoginResponse {
totpChallengeToken: string;
}
export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
@@ -587,8 +699,8 @@ export async function updateFamilyGroup(groupId: string, name: string, token?: s
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
}
export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify({ target }) }, token);
export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
}
export async function fetchFamilyInvites(token?: string | null) {

View File

@@ -21,10 +21,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.561.0",
"next": "^16.0.10",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"tailwind-merge": "^3.4.0",

131
apps/ldap-auth/dns.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
)
var (
ldapDNSServers []string
ldapStaticHosts map[string]string
ldapNetworkingSet bool
)
func initLDAPNetworking() {
if ldapNetworkingSet {
return
}
ldapDNSServers = parseCSV(os.Getenv("LDAP_DNS_SERVERS"))
ldapStaticHosts = parseStaticHostMap(os.Getenv("LDAP_EXTRA_HOSTS"))
ldapNetworkingSet = true
}
func parseCSV(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
func parseStaticHostMap(raw string) map[string]string {
result := make(map[string]string)
for _, part := range parseCSV(raw) {
host, ip, ok := strings.Cut(part, ":")
if !ok {
continue
}
host = strings.TrimSpace(host)
ip = strings.TrimSpace(ip)
if host == "" || ip == "" {
continue
}
result[strings.ToLower(host)] = ip
}
return result
}
func resolveHostAddress(host string) (string, error) {
initLDAPNetworking()
host = strings.TrimSpace(host)
if host == "" {
return "", fmt.Errorf("пустой LDAP host")
}
if net.ParseIP(host) != nil {
return host, nil
}
lowerHost := strings.ToLower(host)
if ip, ok := ldapStaticHosts[lowerHost]; ok {
return ip, nil
}
if len(ldapDNSServers) > 0 {
if ip, err := lookupViaCustomDNS(host, ldapDNSServers); err == nil {
return ip, nil
} else if len(ldapStaticHosts) == 0 {
return "", err
}
}
addrs, err := net.LookupHost(host)
if err != nil {
return "", err
}
if ip := firstIPv4(addrs); ip != "" {
return ip, nil
}
if len(addrs) > 0 {
return addrs[0], nil
}
return "", fmt.Errorf("не найден IP-адрес для %s", host)
}
func lookupViaCustomDNS(host string, servers []string) (string, error) {
var lastErr error
for _, server := range servers {
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
dialer := net.Dialer{Timeout: 4 * time.Second}
return dialer.DialContext(ctx, "udp", net.JoinHostPort(server, "53"))
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
addrs, err := resolver.LookupHost(ctx, host)
cancel()
if err != nil {
lastErr = err
continue
}
if ip := firstIPv4(addrs); ip != "" {
return ip, nil
}
if len(addrs) > 0 {
return addrs[0], nil
}
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("DNS-серверы не вернули адрес для %s", host)
}
func firstIPv4(addrs []string) string {
for _, addr := range addrs {
if strings.Contains(addr, ".") {
return addr
}
}
return ""
}

View File

@@ -140,7 +140,12 @@ func parseTargets(cfg ldapConfig) ([]ldapTarget, error) {
}
func connectTarget(target ldapTarget, skipTlsVerify bool) (*ldap.Conn, error) {
address := fmt.Sprintf("%s:%d", target.host, target.port)
resolvedHost, err := resolveHostAddress(target.host)
if err != nil {
return nil, fmt.Errorf("DNS (%s): %w", target.host, err)
}
address := fmt.Sprintf("%s:%d", resolvedHost, target.port)
if target.useLdaps {
tlsConfig := &tls.Config{
ServerName: target.host,
@@ -170,8 +175,8 @@ func connect(cfg ldapConfig) (*ldap.Conn, error) {
if strings.Contains(lastErr.Error(), "connection refused") {
return nil, fmt.Errorf("не удалось подключиться к LDAP (connection refused). Проверьте host, порт (636 для LDAPS) и доступность контейнера ldap-auth до AD: %w", lastErr)
}
if strings.Contains(lastErr.Error(), "server misbehaving") || strings.Contains(lastErr.Error(), "no such host") || strings.Contains(lastErr.Error(), "lookup ") {
return nil, fmt.Errorf("не удалось разрешить имя LDAP-сервера (DNS). Укажите IP DC в LDAP_HOST или включите LDAP_USE_HOST_NETWORK=true в .env и пересоздайте ldap-auth: %w", lastErr)
if strings.Contains(lastErr.Error(), "server misbehaving") || strings.Contains(lastErr.Error(), "no such host") || strings.Contains(lastErr.Error(), "lookup ") || strings.Contains(lastErr.Error(), "DNS (") {
return nil, fmt.Errorf("не удалось разрешить имя LDAP-сервера (DNS). Добавьте в .env: LDAP_EXTRA_HOSTS=DC-1.mvkug.local:<IP> и LDAP_DNS_SERVERS=<IP>, затем пересоздайте ldap-auth: %w", lastErr)
}
return nil, fmt.Errorf("не удалось подключиться к LDAP: %w", lastErr)
}

View File

@@ -29,6 +29,7 @@
"bcryptjs": "^3.0.3",
"ioredis": "^5.8.2",
"nodemailer": "^7.0.6",
"otplib": "^12.0.1",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",

View File

@@ -73,6 +73,17 @@ model User {
chatRoomMembers ChatRoomMember[]
chatMessages ChatMessage[]
chatPollVotes ChatPollVote[]
totpSecret UserTotpSecret?
}
model UserTotpSecret {
id String @id @default(uuid())
userId String @unique
secretEnc String
isEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserProfile {

View File

@@ -30,6 +30,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 { TotpService } from './domain/totp.service';
@Module({
imports: [
@@ -57,6 +58,7 @@ import { MessagingService } from './infra/messaging.service';
OAuthCoreService,
OtpService,
AdvancedAuthService,
TotpService,
FamilyService,
MediaService,
NotificationsService,

View File

@@ -1,8 +1,48 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes, randomUUID } from 'node:crypto';
import { RedisService } from '../infra/redis.service';
import { AuthService } from './auth.service';
const QR_TTL_SECONDS = 120;
interface StoredQrSession {
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
deviceName: string;
fingerprint: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
auth?: {
accessToken: string;
refreshToken: string;
sessionId: string;
pinVerified: boolean;
expiresAt: string;
requiresTotp?: boolean;
totpChallengeToken?: string;
user: Awaited<ReturnType<AuthService['toPublicUser']>>;
};
expiresAt: string;
}
interface CreateQrSessionInput {
deviceName: string;
fingerprint: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AdvancedAuthService {
constructor(
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly auth: AuthService
) {}
createWebAuthnRegistrationChallenge(userId: string) {
return this.challenge(`webauthn-register:${userId}`);
}
@@ -11,19 +51,111 @@ export class AdvancedAuthService {
return this.challenge(`webauthn-login:${userId}`);
}
createQrSession() {
return {
sessionId: randomUUID(),
status: 'PENDING',
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
};
}
async createQrSession(input: CreateQrSessionInput) {
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + QR_TTL_SECONDS * 1000);
const apiBase = this.config.get<string>('PUBLIC_API_URL') ?? 'http://localhost:3001';
const qrPayload = JSON.stringify({
v: 1,
type: 'idp_qr_login',
sessionId,
apiBase,
expiresAt: expiresAt.toISOString()
});
const data: StoredQrSession = {
status: 'PENDING',
deviceName: input.deviceName,
fingerprint: input.fingerprint,
deviceType: input.deviceType,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
expiresAt: expiresAt.toISOString()
};
await this.redis.client.set(`qr:session:${sessionId}`, JSON.stringify(data), 'EX', QR_TTL_SECONDS);
pollQrSession(sessionId: string) {
return {
sessionId,
status: 'PENDING',
expiresAt: new Date(Date.now() + 2 * 60_000).toISOString()
expiresAt: expiresAt.toISOString(),
qrPayload
};
}
async pollQrSession(sessionId: string) {
const raw = await this.redis.client.get(`qr:session:${sessionId}`);
if (!raw) {
return { sessionId, status: 'EXPIRED', expiresAt: new Date().toISOString() };
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.status === 'PENDING' && new Date(data.expiresAt).getTime() <= Date.now()) {
return { sessionId, status: 'EXPIRED', expiresAt: data.expiresAt };
}
return {
sessionId,
status: data.status,
expiresAt: data.expiresAt,
qrPayload: undefined,
auth: data.auth
? {
accessToken: data.auth.accessToken,
refreshToken: data.auth.refreshToken,
sessionId: data.auth.sessionId,
pinVerified: data.auth.pinVerified,
expiresAt: data.auth.expiresAt,
requiresTotp: data.auth.requiresTotp,
totpChallengeToken: data.auth.totpChallengeToken,
user: data.auth.user
}
: undefined
};
}
async approveQrSession(sessionId: string, userId: string) {
const key = `qr:session:${sessionId}`;
const raw = await this.redis.client.get(key);
if (!raw) {
throw new NotFoundException('QR-сессия не найдена или истекла');
}
const data = JSON.parse(raw) as StoredQrSession;
if (data.status !== 'PENDING') {
throw new BadRequestException('QR-сессия уже обработана');
}
if (new Date(data.expiresAt).getTime() <= Date.now()) {
throw new BadRequestException('QR-сессия истекла');
}
const auth = await this.auth.createQrApprovedLogin(userId, {
fingerprint: data.fingerprint,
deviceName: data.deviceName,
deviceType: data.deviceType,
ipAddress: data.ipAddress,
userAgent: data.userAgent
});
data.status = 'APPROVED';
data.auth = auth;
const ttl = await this.redis.client.ttl(key);
await this.redis.client.set(key, JSON.stringify(data), 'EX', Math.max(ttl, 30));
return {
sessionId,
status: 'APPROVED',
expiresAt: data.expiresAt,
auth: {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
sessionId: auth.sessionId,
pinVerified: auth.pinVerified,
expiresAt: auth.expiresAt,
requiresTotp: auth.requiresTotp,
totpChallengeToken: auth.totpChallengeToken,
user: auth.user
}
};
}

View File

@@ -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 { TotpService } from './totp.service';
import { MessagingService } from '../infra/messaging.service';
@Controller()
@@ -43,7 +44,8 @@ export class AuthGrpcController {
private readonly media: MediaService,
private readonly notifications: NotificationsService,
private readonly chat: ChatService,
private readonly messaging: MessagingService
private readonly messaging: MessagingService,
private readonly totp: TotpService
) {}
@GrpcMethod('AuthService', 'Register')
@@ -62,8 +64,8 @@ export class AuthGrpcController {
}
@GrpcMethod('AuthService', 'SendOtp')
sendPasswordlessOtp(command: { recipient: string; channel?: string }) {
return this.auth.sendOtp(command.recipient, command.channel);
sendPasswordlessOtp(command: { recipient: string; channel?: string; ipAddress?: string }) {
return this.auth.sendOtp(command.recipient, command.channel, command.ipAddress);
}
@GrpcMethod('AuthService', 'VerifyOtp')
@@ -89,6 +91,23 @@ export class AuthGrpcController {
return this.auth.loginWithLdap(command);
}
@GrpcMethod('AuthService', 'VerifyTotpLogin')
verifyTotpLogin(command: { totpChallengeToken: string; code: string }) {
return this.auth.verifyTotpLogin(command.totpChallengeToken, command.code);
}
@GrpcMethod('AuthService', 'BeginTotpLogin')
beginTotpLogin(command: {
recipient: string;
fingerprint: string;
deviceName?: string;
deviceType?: string;
ipAddress?: string;
userAgent?: string;
}) {
return this.auth.beginTotpLogin(command);
}
@GrpcMethod('AuthService', 'VerifyPin')
verifyPin(command: VerifyPinCommand) {
return this.pin.verifyPin(command.sessionId, command.pin);
@@ -283,8 +302,8 @@ export class AuthGrpcController {
}
@GrpcMethod('SecurityService', 'ListActiveDevices')
async listActiveDevices(command: { userId: string }) {
const devices = await this.security.listActiveDevices(command.userId);
async listActiveDevices(command: { userId: string; exceptSessionId?: string }) {
const devices = await this.security.listActiveDevices(command.userId, command.exceptSessionId);
return {
devices: devices.map((device) => ({
id: device.id,
@@ -337,11 +356,31 @@ export class AuthGrpcController {
}
@GrpcMethod('SecurityService', 'RevokeAllSessions')
async revokeAllSessions(command: { userId: string }) {
const result = await this.security.revokeAllSessions(command.userId);
async revokeAllSessions(command: { userId: string; exceptSessionId?: string }) {
const result = await this.security.revokeAllSessions(command.userId, command.exceptSessionId);
return { count: result.count };
}
@GrpcMethod('SecurityService', 'GetTotpStatus')
getTotpStatus(command: { userId: string }) {
return this.totp.getStatus(command.userId);
}
@GrpcMethod('SecurityService', 'SetupTotp')
setupTotp(command: { userId: string }) {
return this.totp.setup(command.userId);
}
@GrpcMethod('SecurityService', 'EnableTotp')
enableTotp(command: { userId: string; code: string }) {
return this.totp.enable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'DisableTotp')
disableTotp(command: { userId: string; code: string }) {
return this.totp.disable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'SetupPin')
setupPin(command: { userId: string; pin: string }) {
return this.pin.setupPin(command.userId, command.pin);
@@ -459,7 +498,7 @@ export class AuthGrpcController {
}
@GrpcMethod('ProfileService', 'UpdateProfile')
updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string }) {
updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string; birthDate?: string }) {
return this.profile.updateProfile(command);
}
@@ -621,8 +660,14 @@ export class AuthGrpcController {
}
@GrpcMethod('AdvancedAuthService', 'CreateQrSession')
createQrSession(command: { deviceName: string }) {
return this.advancedAuth.createQrSession();
createQrSession(command: { deviceName: string; fingerprint?: string; deviceType?: string; ipAddress?: string; userAgent?: string }) {
return this.advancedAuth.createQrSession({
deviceName: command.deviceName,
fingerprint: command.fingerprint ?? command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent
});
}
@GrpcMethod('AdvancedAuthService', 'PollQrSession')
@@ -630,6 +675,11 @@ export class AuthGrpcController {
return this.advancedAuth.pollQrSession(command.sessionId);
}
@GrpcMethod('AdvancedAuthService', 'ApproveQrSession')
approveQrSession(command: { sessionId: string; userId: string }) {
return this.advancedAuth.approveQrSession(command.sessionId, command.userId);
}
@GrpcMethod('FamilyService', 'CreateFamilyGroup')
createFamilyGroup(command: { ownerId: string; name: string }) {
return this.family.create(command.ownerId, command.name);
@@ -666,8 +716,16 @@ export class AuthGrpcController {
}
@GrpcMethod('FamilyService', 'SendFamilyInvite')
sendFamilyInvite(command: { requesterId: string; groupId: string; target: string }) {
return this.family.sendInvite(command.requesterId, command.groupId, command.target);
sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
return this.family.sendInvite(command.requesterId, command.groupId, {
target: command.target,
inviteeUserId: command.inviteeUserId
});
}
@GrpcMethod('FamilyService', 'SearchFamilyInviteUsers')
searchFamilyInviteUsers(command: { requesterId: string; groupId: string; query: string }) {
return this.family.searchInviteUsers(command.requesterId, command.groupId, command.query);
}
@GrpcMethod('FamilyService', 'RespondFamilyInvite')

View File

@@ -12,8 +12,14 @@ import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto';
import { AccessService } from './access.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { NotificationsService } from './notifications.service';
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
import { TotpService } from './totp.service';
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
type DeviceCommand = Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;
@Injectable()
export class AuthService {
@@ -25,7 +31,9 @@ export class AuthService {
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly ldapClient: LdapClientService,
private readonly messaging: MessagingService
private readonly messaging: MessagingService,
private readonly notifications: NotificationsService,
private readonly totp: TotpService
) {}
async register(command: RegisterCommand): Promise<PublicUser> {
@@ -95,28 +103,99 @@ export class AuthService {
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
}
async beginTotpLogin(command: DeviceCommand & { recipient: string }) {
const user = await this.findUserByRecipient(command.recipient);
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Пользователь не найден');
}
if (!(await this.totp.isEnabledForUser(user.id))) {
throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
}
const device = await this.upsertDevice(user.id, command);
const challenge = await this.issueTotpChallenge(
user,
command,
{ ...command, login: command.recipient, password: '' },
device.id
);
return { totpChallengeToken: challenge.totpChallengeToken };
}
async verifyTotpLogin(totpChallengeToken: string, code: string) {
let payload: { sub: string; typ: string; jti?: string };
try {
payload = await this.jwt.verifyAsync(totpChallengeToken, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
issuer: 'id.lendry.ru'
});
} catch {
throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
}
if (payload.typ !== 'totp_login_challenge' || !payload.jti) {
throw new UnauthorizedException('Некорректный токен подтверждения');
}
const challengeKey = this.totpLoginChallengeKey(payload.jti);
const raw = await this.redis.client.get(challengeKey);
if (!raw) {
throw new UnauthorizedException('Сессия входа истекла, повторите попытку');
}
const challenge = JSON.parse(raw) as DeviceCommand & { userId: string; deviceId: string; signIn: LoginCommand };
if (challenge.userId !== payload.sub) {
throw new UnauthorizedException('Некорректный токен подтверждения');
}
const attemptKey = this.totpLoginAttemptKey(payload.jti);
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 isValid = await this.totp.verifyEnabledCode(payload.sub, code);
if (!isValid) {
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', TOTP_LOGIN_CHALLENGE_TTL_SECONDS);
throw new UnauthorizedException('Неверный код аутентификатора');
}
await this.redis.client.del(challengeKey);
await this.redis.client.del(attemptKey);
const user = await this.prisma.user.findFirst({
where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
return auth;
}
async identify(login: string) {
const user = await this.findUserByRecipient(login);
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
const otpChannels = user ? this.buildOtpChannels(user) : [];
if (user?.passwordHash) {
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
}
if (user) {
const channels: Array<{ channel: string; value: string | null }> = [
{ channel: 'email', value: user.email },
{ channel: 'phone', value: user.phone },
const backupChannels: Array<{ channel: string; value: string | null }> = [
{ channel: 'backupEmail', value: user.backupEmail },
{ channel: 'backupPhone', value: user.backupPhone }
];
for (const item of channels) {
if (item.value && item.value !== login) {
for (const item of backupChannels) {
if (item.value) {
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
}
}
@@ -126,13 +205,15 @@ export class AuthService {
exists: Boolean(user),
hasPassword: Boolean(user?.passwordHash),
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
otpChannels,
methods
};
}
async sendOtp(recipient: string, channel?: string) {
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = this.resolveOtpTarget(recipient, channel, existingUser);
const target = this.normalizeOtpTarget(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));
@@ -153,18 +234,37 @@ export class AuthService {
code,
expiryMinutes
});
if (existingUser) {
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
}
await this.redis.client.del(this.otpAttemptKey(target));
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
}
private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
const ipLabel = ipAddress?.trim() || 'неизвестного IP';
await this.notifications.create(
userId,
'otp_code',
action,
`Попытка входа с ${ipLabel} — код для верификации: ${code}`,
{ code, ipAddress: ipAddress ?? null, action },
{ skipPublish: false }
);
}
async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
const existingUser = await this.findUserByRecipient(command.recipient);
const targetVariants = this.recipientLookupTargets(command.recipient);
const authCode = await this.prisma.authCode.findFirst({
where: {
purpose: 'passwordless-login',
usedAt: null,
expiresAt: { gt: new Date() },
OR: [{ target: command.recipient }, ...(existingUser ? [{ userId: existingUser.id }] : [])]
OR: [
{ target: { in: targetVariants } },
...(existingUser ? [{ userId: existingUser.id }] : [])
]
},
orderBy: { createdAt: 'desc' }
});
@@ -178,7 +278,8 @@ export class AuthService {
throw new UnauthorizedException('Превышено число попыток ввода кода');
}
const matches = await bcrypt.compare(command.code, authCode.codeHash);
const normalizedCode = command.code.replace(/\s/g, '');
const matches = await bcrypt.compare(normalizedCode, authCode.codeHash);
if (!matches) {
await this.redis.client.set(attemptKey, String(attempts + 1), 'EX', 15 * 60);
throw new UnauthorizedException('Неверный код входа');
@@ -189,9 +290,28 @@ export class AuthService {
const user = await this.findOrCreatePasswordlessUser(command.recipient);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id);
return { requiresPassword: false, tempAuthToken: undefined, auth };
const auth = await this.finalizeAuthentication(
user,
command,
{ ...command, login: command.recipient, password: '' },
device.id,
{ skipTotp: true }
);
return {
requiresPassword: false,
auth
};
}
private buildOtpChannels(user: { email: string | null; phone: string | null }) {
const channels: Array<{ channel: string; masked: string }> = [];
if (user.email) {
channels.push({ channel: 'email', masked: this.maskTarget(user.email) });
}
if (user.phone) {
channels.push({ channel: 'phone', masked: this.maskTarget(user.phone) });
}
return channels;
}
private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
@@ -231,9 +351,38 @@ export class AuthService {
throw new UnauthorizedException('Неверный пароль');
}
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(user.id, true, command, device.id);
return auth;
return this.finalizeAuthentication(user, command, command, device.id, { skipTotp: true });
}
async createQrApprovedLogin(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
if (!user) {
throw new UnauthorizedException('Пользователь не найден или заблокирован');
}
const device = await this.upsertDevice(user.id, {
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress
});
return this.finalizeAuthentication(
user,
command,
{
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent,
login: 'qr-login',
password: ''
},
device.id,
{ skipTotp: true }
);
}
async loginWithLdap(command: {
@@ -261,14 +410,21 @@ export class AuthService {
const user = await this.findOrCreateLdapUser(ldapResult);
const device = await this.upsertDevice(user.id, command);
const auth = await this.createAuthTokens(user, device.id, command);
await this.writeSignInEvent(
user.id,
true,
{ login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent },
device.id
return this.finalizeAuthentication(
user,
command,
{
login: command.username,
password: '',
fingerprint: command.fingerprint,
deviceName: command.deviceName,
deviceType: command.deviceType,
ipAddress: command.ipAddress,
userAgent: command.userAgent
},
device.id,
{ skipTotp: true }
);
return auth;
}
private async getLdapConfig(): Promise<LdapConnectionConfig> {
@@ -364,6 +520,66 @@ export class AuthService {
}
}
private async finalizeAuthentication(
user: User & { pinCode?: { isEnabled: boolean } | null },
deviceCommand: DeviceCommand,
signInCommand: LoginCommand,
deviceId: string,
options?: { skipTotp?: boolean }
): Promise<AuthTokens> {
if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId);
}
const auth = await this.createAuthTokens(user, deviceId, deviceCommand);
await this.writeSignInEvent(user.id, true, signInCommand, deviceId);
return auth;
}
private async issueTotpChallenge(
user: User & { pinCode?: { isEnabled: boolean } | null },
deviceCommand: DeviceCommand,
signInCommand: LoginCommand,
deviceId: string
): Promise<AuthTokens> {
const challengeId = randomBytes(16).toString('base64url');
const totpChallengeToken = await this.jwt.signAsync(
{ sub: user.id, typ: 'totp_login_challenge', jti: challengeId },
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' }
);
await this.redis.client.set(
this.totpLoginChallengeKey(challengeId),
JSON.stringify({
userId: user.id,
deviceId,
signIn: signInCommand,
...deviceCommand
}),
'EX',
TOTP_LOGIN_CHALLENGE_TTL_SECONDS
);
return {
requiresTotp: true,
totpChallengeToken,
accessToken: '',
refreshToken: '',
expiresAt: new Date().toISOString(),
pinVerified: false,
sessionId: '',
user: await this.toPublicUser(user)
};
}
private totpLoginChallengeKey(challengeId: string) {
return `totp:login:${challengeId}`;
}
private totpLoginAttemptKey(challengeId: string) {
return `totp:login:attempts:${challengeId}`;
}
private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
return this.prisma.device.upsert({
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
@@ -400,6 +616,8 @@ export class AuthService {
}
});
await this.notifications.dismissOtpCodeNotifications(user.id);
return {
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
refreshToken,
@@ -417,11 +635,31 @@ export class AuthService {
);
}
private normalizeOtpTarget(target: string) {
if (target.includes('@')) {
return target.trim().toLowerCase();
}
return canonicalPhoneE164(target);
}
private recipientLookupTargets(recipient: string) {
if (recipient.includes('@')) {
return [recipient.trim().toLowerCase()];
}
return phoneLookupVariants(recipient);
}
private async findUserByRecipient(recipient: string) {
if (recipient.includes('@')) {
return this.prisma.user.findFirst({
where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}
const variants = phoneLookupVariants(recipient);
return this.prisma.user.findFirst({
where: recipient.includes('@')
? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE }
: { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE },
where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE },
include: { pinCode: true }
});
}

View File

@@ -23,6 +23,8 @@ export interface AuthTokens {
pinVerified: boolean;
sessionId: string;
user: PublicUser;
requiresTotp?: boolean;
totpChallengeToken?: string;
}
export interface PublicUser {

View File

@@ -276,112 +276,109 @@ export class FamilyService {
async sendInvite(requesterId: string, groupId: string, target: string) {
async sendInvite(requesterId: string, groupId: string, options: { target?: string; inviteeUserId?: string }) {
const group = await this.getGroupWithMembers(groupId);
this.assertMember(group, requesterId);
const trimmedTarget = target.trim();
if (!trimmedTarget) {
throw new BadRequestException('Укажите email, телефон или логин приглашаемого');
}
const invitee = await this.findUserByContact(trimmedTarget);
if (invitee?.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя');
}
if (invitee && group.members.some((member) => member.userId === invitee.id)) {
throw new BadRequestException('Пользователь уже состоит в семье');
}
const existingPending = invitee
? await this.prisma.familyInvite.findFirst({
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
const invitee = options.inviteeUserId
? await this.prisma.user.findFirst({
where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null }
})
: options.target
? await this.findUserByContact(options.target)
: null;
: null;
if (existingPending) {
throw new BadRequestException('Приглашение уже отправлено');
if (!invitee) {
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
}
if (invitee.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя');
}
if (group.members.some((member) => member.userId === invitee.id)) {
throw new BadRequestException('Пользователь уже состоит в семье');
}
const existingPending = await this.prisma.familyInvite.findFirst({
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
});
if (existingPending) {
throw new BadRequestException('Приглашение уже отправлено');
}
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
const invite = await this.prisma.familyInvite.create({
data: {
groupId,
inviterId: requesterId,
inviteeUserId: invitee?.id,
targetEmail: trimmedTarget.includes('@') ? trimmedTarget : undefined,
targetPhone: !trimmedTarget.includes('@') && /^\+?\d/.test(trimmedTarget) ? trimmedTarget : undefined,
inviteeUserId: invitee.id,
targetEmail: invitee.email ?? undefined,
targetPhone: invitee.phone ?? undefined,
expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000)
},
include: {
group: true,
inviter: true
}
});
if (invitee) {
await this.notifications.create(
invitee.id,
'family_invite',
'Приглашение в семью',
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
{ inviteId: invite.id, groupId, groupName: group.name }
);
}
await this.notifications.create(
invitee.id,
'family_invite',
'Приглашение в семью',
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
{ inviteId: invite.id, groupId, groupName: group.name }
);
return this.toInvite(invite);
}
async searchInviteUsers(requesterId: string, groupId: string, query: string) {
const group = await this.getGroupWithMembers(groupId);
this.assertMember(group, requesterId);
const trimmed = query.trim();
if (trimmed.length < 2) {
return { users: [] };
}
const excludedIds = [...group.members.map((member) => member.userId), requesterId];
const digits = trimmed.replace(/\D/g, '');
const users = await this.prisma.user.findMany({
where: {
status: 'ACTIVE',
deletedAt: null,
id: { notIn: excludedIds },
OR: [
{ displayName: { contains: trimmed, mode: 'insensitive' } },
{ username: { contains: trimmed, mode: 'insensitive' } },
...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []),
...(digits.length >= 4 ? [{ phone: { contains: digits } }] : [])
]
},
orderBy: { displayName: 'asc' },
take: 8,
select: {
id: true,
displayName: true,
email: true,
phone: true,
username: true,
avatarStorageKey: true
}
});
return {
users: users.map((user) => ({
id: user.id,
displayName: user.displayName,
email: user.email,
phone: user.phone,
username: user.username,
hasAvatar: Boolean(user.avatarStorageKey)
}))
};
}
@@ -610,27 +607,38 @@ export class FamilyService {
private async findUserByContact(target: string) {
if (target.includes('@')) {
return this.prisma.user.findFirst({ where: { email: target, status: 'ACTIVE' } });
const trimmed = target.trim();
if (!trimmed) {
return null;
}
const normalizedPhone = target.replace(/\D/g, '');
if (trimmed.includes('@')) {
return this.prisma.user.findFirst({
where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null }
});
}
const digits = trimmed.replace(/\D/g, '');
if (/^\+?\d/.test(trimmed) && digits.length >= 10) {
return this.prisma.user.findFirst({
where: {
status: 'ACTIVE',
deletedAt: null,
OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }]
}
});
}
return this.prisma.user.findFirst({
where: {
status: 'ACTIVE',
OR: [{ phone: target }, { phone: { contains: normalizedPhone.slice(-10) } }, { username: target }]
deletedAt: null,
OR: [
{ username: { equals: trimmed, mode: 'insensitive' } },
{ displayName: { equals: trimmed, mode: 'insensitive' } }
]
}
});
}

View File

@@ -16,6 +16,15 @@ interface MediaStreamPayload {
fileName?: string;
}
interface MediaUploadPayload {
purpose: 'media-upload';
storageKey: string;
contentType: string;
userId: string;
}
const UPLOAD_TTL_SECONDS = 300;
@Injectable()
export class MediaService {
constructor(
@@ -34,8 +43,7 @@ export class MediaService {
}
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `avatars/${userId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(userId, storageKey, contentType);
}
async confirmAvatar(userId: string, storageKey: string) {
@@ -102,8 +110,7 @@ export class MediaService {
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(userId, storageKey, contentType);
}
async resolveStreamToken(token: string, requesterId?: string) {
@@ -186,8 +193,7 @@ export class MediaService {
}
const extension = this.minio.extensionForContentType(contentType);
const storageKey = `families/${groupId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType);
return { ...presigned, storageKey };
return this.createUploadTarget(requesterId, storageKey, contentType);
}
async confirmFamilyAvatar(requesterId: string, groupId: string, storageKey: string) {
@@ -221,8 +227,7 @@ export class MediaService {
}
const extension = this.minio.extensionForChatMedia(normalized, fileName);
const storageKey = `chat/${roomId}/${crypto.randomUUID()}.${extension}`;
const presigned = await this.minio.createPresignedUploadUrl(storageKey, normalized);
return { ...presigned, storageKey };
return this.createUploadTarget(requesterId, storageKey, normalized);
}
async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) {
@@ -241,6 +246,37 @@ export class MediaService {
return roomId;
}
private async createUploadTarget(userId: string, storageKey: string, contentType: string) {
const viaApi = this.config.get<string>('MINIO_UPLOAD_VIA_API', 'true') !== 'false';
const expiresAt = new Date(Date.now() + UPLOAD_TTL_SECONDS * 1000).toISOString();
if (viaApi) {
const uploadToken = await this.jwt.signAsync(
{
purpose: 'media-upload',
storageKey,
contentType,
userId
} satisfies MediaUploadPayload,
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: UPLOAD_TTL_SECONDS,
issuer: 'id.lendry.ru'
}
);
const publicApiUrl = this.config.get<string>('PUBLIC_API_URL', 'http://localhost:3001').replace(/\/$/, '');
return {
uploadUrl: `${publicApiUrl}/media/upload`,
uploadToken,
storageKey,
expiresAt
};
}
const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType, UPLOAD_TTL_SECONDS);
return { ...presigned, storageKey };
}
private async createStreamAccessUrl(
storageKey: string,
ownerId: string,

View File

@@ -121,6 +121,13 @@ export class NotificationsService {
return { count: result.count };
}
async dismissOtpCodeNotifications(userId: string) {
const result = await this.prisma.notification.deleteMany({
where: { userId, type: 'otp_code' }
});
return { count: result.count };
}
async dismissFamilyInvite(userId: string, inviteId: string) {
const items = await this.prisma.notification.findMany({
where: { userId, type: 'family_invite' }

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service';
import { RedisService } from '../infra/redis.service';
import { NotificationsService } from './notifications.service';
@Injectable()
export class OtpService {
@@ -12,10 +13,11 @@ export class OtpService {
private readonly prisma: PrismaService,
private readonly settings: SettingsService,
private readonly messaging: MessagingService,
private readonly redis: RedisService
private readonly redis: RedisService,
private readonly notifications: NotificationsService
) {}
async send(command: { target: string; channel: string; purpose: string; userId?: string }) {
async send(command: { target: string; channel: string; purpose: string; userId?: string; ipAddress?: string }) {
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(randomInt(100000, 999999));
const expiresAt = new Date(Date.now() + expiryMinutes * 60_000);
@@ -36,6 +38,16 @@ export class OtpService {
code,
expiryMinutes
});
if (command.userId) {
const ipLabel = command.ipAddress?.trim() || 'неизвестного IP';
await this.notifications.create(
command.userId,
'otp_code',
this.purposeLabel(command.purpose),
`Попытка входа с ${ipLabel} — код для верификации: ${code}`,
{ code, ipAddress: command.ipAddress ?? null, purpose: command.purpose }
);
}
await this.redis.client.del(this.attemptKey(command.target, command.purpose));
return { sent: true, expiresAt: expiresAt.toISOString() };
}
@@ -67,10 +79,26 @@ export class OtpService {
}
await this.redis.client.del(attemptKey);
await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } });
if (authCode.userId) {
await this.notifications.dismissOtpCodeNotifications(authCode.userId);
}
return { verified: true };
}
private attemptKey(target: string, purpose: string) {
return `otp:attempts:${purpose}:${target.toLowerCase()}`;
}
private purposeLabel(purpose: string) {
switch (purpose) {
case 'register':
return 'Регистрация';
case 'login':
return 'Вход в аккаунт';
case 'password-reset':
return 'Сброс пароля';
default:
return 'Подтверждение действия';
}
}
}

View File

@@ -9,19 +9,13 @@ import { PrismaService } from '../infra/prisma.service';
interface UpdateProfileCommand {
userId: string;
firstName?: string;
lastName?: string;
bio?: string;
age?: number;
gender?: string;
birthDate?: string;
}
@@ -151,63 +145,36 @@ export class ProfileService {
async updateProfile(command: UpdateProfileCommand) {
const displayName = [command.firstName, command.lastName].filter(Boolean).join(' ').trim();
const birthDate = command.birthDate ? this.parseBirthDate(command.birthDate) : undefined;
const user = await this.prisma.user.update({
where: { id: command.userId },
data: {
displayName: displayName || undefined,
gender: command.gender,
birthDate,
profile: {
upsert: {
create: {
firstName: command.firstName,
lastName: command.lastName,
bio: command.bio,
age: command.age,
gender: command.gender
},
update: {
firstName: command.firstName,
lastName: command.lastName,
bio: command.bio,
age: command.age,
gender: command.gender
}
}
}
},
include: { profile: true }
});
return this.toResponse(user);
}
@@ -430,12 +397,30 @@ export class ProfileService {
backupEmail: user.backupEmail,
backupPhone: user.backupPhone
backupPhone: user.backupPhone,
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null
};
}
private parseBirthDate(value: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException('Укажите корректную дату рождения');
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Укажите корректную дату рождения');
}
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
if (parsed.getTime() > today.getTime()) {
throw new BadRequestException('Дата рождения не может быть в будущем');
}
return parsed;
}
}

View File

@@ -6,9 +6,21 @@ import { PrismaService } from '../infra/prisma.service';
export class SecurityService {
constructor(private readonly prisma: PrismaService) {}
async listActiveDevices(userId: string) {
return this.prisma.device.findMany({
where: { userId },
async listActiveDevices(userId: string, exceptSessionId?: string) {
let exceptDeviceId: string | undefined;
if (exceptSessionId) {
const currentSession = await this.prisma.session.findFirst({
where: { id: exceptSessionId, userId },
select: { deviceId: true }
});
exceptDeviceId = currentSession?.deviceId ?? undefined;
}
const devices = await this.prisma.device.findMany({
where: {
userId,
...(exceptDeviceId ? { id: { not: exceptDeviceId } } : {})
},
orderBy: { lastSeenAt: 'desc' },
include: {
sessions: {
@@ -17,6 +29,8 @@ export class SecurityService {
}
}
});
return devices.filter((device) => device.sessions.length > 0);
}
async listActiveSessions(userId: string) {
@@ -57,9 +71,13 @@ export class SecurityService {
});
}
async revokeAllSessions(userId: string) {
async revokeAllSessions(userId: string, exceptSessionId?: string) {
return this.prisma.session.updateMany({
where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } },
where: {
userId,
status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] },
...(exceptSessionId ? { id: { not: exceptSessionId } } : {})
},
data: { status: SessionStatus.REVOKED }
});
}

View File

@@ -0,0 +1,163 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { authenticator } from 'otplib';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
@Injectable()
export class TotpService {
private readonly totp = authenticator.clone({ window: 2 });
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly settings: SettingsService
) {}
async getStatus(userId: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
return { isEnabled: Boolean(record?.isEnabled) };
}
async isEnabledForUser(userId: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
return Boolean(record?.isEnabled);
}
async setup(userId: string) {
await this.ensureUserExists(userId);
const issuer = await this.resolveIssuer();
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const accountLabel = this.buildAccountLabel(user, userId);
const existing = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (existing && !existing.isEnabled) {
const secret = this.tryDecryptSecret(existing.secretEnc);
if (secret) {
return {
secret,
otpauthUrl: this.totp.keyuri(accountLabel, issuer, secret)
};
}
}
const secret = this.totp.generateSecret();
const secretEnc = this.encryption.encrypt(secret);
this.assertSecretRoundTrip(secret, secretEnc);
const otpauthUrl = this.totp.keyuri(accountLabel, issuer, secret);
await this.prisma.userTotpSecret.upsert({
where: { userId },
create: {
userId,
secretEnc,
isEnabled: false
},
update: {
secretEnc,
isEnabled: false
}
});
return { secret, otpauthUrl };
}
async enable(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record) {
throw new BadRequestException('Сначала начните настройку приложения-аутентификатора');
}
if (record.isEnabled) {
return { isEnabled: true };
}
this.assertValidCode(record.secretEnc, code);
await this.prisma.userTotpSecret.update({
where: { userId },
data: { isEnabled: true }
});
return { isEnabled: true };
}
async disable(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {
throw new BadRequestException('Двухфакторная аутентификация не включена');
}
this.assertValidCode(record.secretEnc, code);
await this.prisma.userTotpSecret.delete({ where: { userId } });
return { isEnabled: false };
}
async verifyEnabledCode(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {
return false;
}
return this.isValidCode(record.secretEnc, code);
}
private async resolveIssuer() {
const projectName = (await this.settings.getValue('PROJECT_NAME', 'MVK ID')).trim();
return projectName || 'MVK ID';
}
private buildAccountLabel(
user: { email: string | null; phone: string | null; username: string | null } | null,
userId: string
) {
return user?.email ?? user?.phone ?? user?.username ?? userId;
}
private tryDecryptSecret(secretEnc: string) {
try {
const secret = this.encryption.decrypt(secretEnc).trim();
return secret.length > 0 ? secret : null;
} catch {
return null;
}
}
private assertSecretRoundTrip(secret: string, secretEnc: string) {
const decrypted = this.encryption.decrypt(secretEnc);
if (decrypted !== secret) {
throw new InternalServerErrorException('Не удалось сохранить секрет аутентификатора');
}
const probeToken = this.totp.generate(secret);
if (!this.totp.verify({ token: probeToken, secret: decrypted })) {
throw new InternalServerErrorException('Не удалось проверить секрет аутентификатора');
}
}
private assertValidCode(secretEnc: string, code: string) {
if (!this.isValidCode(secretEnc, code)) {
throw new BadRequestException(
'Неверный код аутентификатора. Убедитесь, что отсканирован последний QR-код, и повторите настройку при необходимости.'
);
}
}
private isValidCode(secretEnc: string, code: string) {
const normalized = code.replace(/\s/g, '');
if (!/^\d{6}$/.test(normalized)) {
return false;
}
const secret = this.tryDecryptSecret(secretEnc);
if (!secret) {
return false;
}
return this.totp.verify({ token: normalized, secret });
}
private async ensureUserExists(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.deletedAt) {
throw new NotFoundException('Пользователь не найден');
}
}
}

View File

@@ -0,0 +1,39 @@
export function phoneLookupVariants(value: string): string[] {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, '');
const variants = new Set<string>([trimmed, digits]);
if (digits.length === 11 && digits.startsWith('8')) {
const normalized = `7${digits.slice(1)}`;
variants.add(normalized);
variants.add(`+${normalized}`);
} else if (digits.length === 11 && digits.startsWith('7')) {
variants.add(`+${digits}`);
variants.add(`8${digits.slice(1)}`);
} else if (digits.length === 10) {
variants.add(`7${digits}`);
variants.add(`+7${digits}`);
}
if (trimmed.startsWith('+') && digits) {
variants.add(`+${digits}`);
}
return [...variants];
}
export function canonicalPhoneE164(value: string): string {
const digits = value.replace(/\D/g, '');
if (!digits) {
return value.trim();
}
let normalized = digits;
if (normalized.length === 11 && normalized.startsWith('8')) {
normalized = `7${normalized.slice(1)}`;
} else if (normalized.length === 10) {
normalized = `7${normalized}`;
}
return `+${normalized}`;
}

View File

@@ -184,6 +184,8 @@ services:
restart: unless-stopped
environment:
PORT: 8086
LDAP_DNS_SERVERS: ${LDAP_DNS_SERVERS:-}
LDAP_EXTRA_HOSTS: ${LDAP_EXTRA_HOSTS:-}
ports:
- "8086:8086"
healthcheck:

View File

@@ -957,9 +957,54 @@ persist_domain_env() {
env_set LDAP_EXTRA_HOSTS "$(env_get LDAP_EXTRA_HOSTS "")"
ok "Файл .env обновлён"
derive_ldap_dns_settings
}
derive_ldap_dns_settings() {
load_env
local extra dns first ip
extra="$(env_get LDAP_EXTRA_HOSTS "")"
[[ -n "$extra" ]] || return 0
dns="$(env_get LDAP_DNS_SERVERS "")"
if [[ -z "$dns" ]]; then
first="${extra%%,*}"
ip="${first##*:}"
ip="$(echo "$ip" | xargs)"
if [[ -n "$ip" ]]; then
env_set LDAP_DNS_SERVERS "$ip"
log "LDAP_DNS_SERVERS=${ip} (из LDAP_EXTRA_HOSTS)"
fi
fi
}
sync_ldap_system_hosts() {
load_env
local extra
extra="$(env_get LDAP_EXTRA_HOSTS "")"
[[ -n "$extra" ]] || return 0
log "LDAP: запись имён DC в /etc/hosts..."
local entry host ip
IFS=',' read -ra _entries <<< "$extra"
for entry in "${_entries[@]}"; do
entry="$(echo "$entry" | xargs)"
[[ "$entry" == *:* ]] || continue
host="${entry%%:*}"
host="$(echo "$host" | xargs)"
ip="${entry##*:}"
ip="$(echo "$ip" | xargs)"
[[ -n "$host" && -n "$ip" ]] || continue
if run_root grep -qE "[[:space:]]${host}([[:space:]]|$)" /etc/hosts 2>/dev/null; then
continue
fi
run_root sh -c "printf '%s %s\n' '${ip}' '${host}' >> /etc/hosts" || warn "Не удалось добавить ${host} в /etc/hosts (нужен sudo?)"
done
}
write_compose_override() {
derive_ldap_dns_settings
sync_ldap_system_hosts
resolve_nginx_mode_and_ports
load_env
local mode="${NGINX_MODE:-$(env_get NGINX_MODE host)}"
@@ -2035,6 +2080,10 @@ action_fix_host_proxy() {
apply_intranet_selfsigned_runtime
log "Шаг 5/5: пересоздание ldap-auth и sso-core..."
build_compose_stack_cmd
docker_cmd "${COMPOSE_STACK_CMD[@]}" up -d --build --force-recreate ldap-auth sso-core
ok "Прокси исправлен — проверьте: curl -I http://127.0.0.1:3002"
show_status
}

125
package-lock.json generated
View File

@@ -116,10 +116,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.561.0",
"next": "^16.0.10",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"tailwind-merge": "^3.4.0",
@@ -188,6 +191,7 @@
"bcryptjs": "^3.0.3",
"ioredis": "^5.8.2",
"nodemailer": "^7.0.6",
"otplib": "^12.0.1",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
@@ -853,6 +857,12 @@
"node": ">=12"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
@@ -2575,6 +2585,56 @@
"node": ">= 10"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@@ -6910,6 +6970,22 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -9135,6 +9211,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9574,6 +9661,15 @@
],
"license": "MIT"
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -9639,6 +9735,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -10588,6 +10705,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -10,6 +10,8 @@ service AuthService {
rpc VerifyOtp (PasswordlessVerifyRequest) returns (PasswordlessAuthResponse);
rpc LoginWithPassword (PasswordLoginRequest) returns (AuthTokens);
rpc LoginWithLdap (LdapLoginRequest) returns (AuthTokens);
rpc VerifyTotpLogin (VerifyTotpLoginRequest) returns (AuthTokens);
rpc BeginTotpLogin (BeginTotpLoginRequest) returns (BeginTotpLoginResponse);
rpc VerifyPin (VerifyPinRequest) returns (PinVerificationResponse);
rpc GetMe (GetMeRequest) returns (PublicUser);
rpc RefreshSession (RefreshSessionRequest) returns (RefreshSessionResponse);
@@ -43,6 +45,13 @@ message IdentifyResponse {
bool hasPassword = 2;
bool isPinEnabled = 3;
repeated LoginMethod methods = 4;
bool isTotpEnabled = 5;
repeated OtpChannel otpChannels = 6;
}
message OtpChannel {
string channel = 1;
string masked = 2;
}
message LoginMethod {
@@ -54,6 +63,7 @@ message LoginMethod {
message PasswordlessOtpRequest {
string recipient = 1;
optional string channel = 2;
optional string ipAddress = 3;
}
message PasswordlessOtpResponse {
@@ -76,6 +86,8 @@ message PasswordlessAuthResponse {
bool requiresPassword = 1;
optional string tempAuthToken = 2;
optional AuthTokens auth = 3;
bool requiresTotp = 4;
optional string totpChallengeToken = 5;
}
message PasswordLoginRequest {
@@ -99,6 +111,24 @@ message LdapLoginRequest {
optional string userAgent = 7;
}
message VerifyTotpLoginRequest {
string totpChallengeToken = 1;
string code = 2;
}
message BeginTotpLoginRequest {
string recipient = 1;
string fingerprint = 2;
optional string deviceName = 3;
optional string deviceType = 4;
optional string ipAddress = 5;
optional string userAgent = 6;
}
message BeginTotpLoginResponse {
string totpChallengeToken = 1;
}
message VerifyPinRequest {
string sessionId = 1;
string pin = 2;
@@ -163,6 +193,8 @@ message AuthTokens {
bool pinVerified = 4;
PublicUser user = 5;
string sessionId = 6;
bool requiresTotp = 7;
optional string totpChallengeToken = 8;
}
message PinVerificationResponse {

View File

@@ -18,6 +18,7 @@ service AdvancedAuthService {
rpc CreateWebAuthnLoginChallenge (WebAuthnRequest) returns (ChallengeResponse);
rpc CreateQrSession (QrSessionRequest) returns (QrSessionResponse);
rpc PollQrSession (QrPollRequest) returns (QrSessionResponse);
rpc ApproveQrSession (ApproveQrSessionRequest) returns (QrSessionResponse);
}
service FamilyService {
@@ -29,6 +30,7 @@ service FamilyService {
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
rpc ListFamilyInvites (ListFamilyInvitesRequest) returns (ListFamilyInvitesResponse);
}
@@ -81,6 +83,7 @@ message SendOtpRequest {
string channel = 2;
string purpose = 3;
optional string userId = 4;
optional string ipAddress = 5;
}
message VerifyOtpRequest {
@@ -110,6 +113,10 @@ message ChallengeResponse {
message QrSessionRequest {
string deviceName = 1;
optional string fingerprint = 2;
optional string deviceType = 3;
optional string ipAddress = 4;
optional string userAgent = 5;
}
message QrPollRequest {
@@ -120,6 +127,21 @@ message QrSessionResponse {
string sessionId = 1;
string status = 2;
string expiresAt = 3;
optional string qrPayload = 4;
optional QrAuthPayload auth = 5;
}
message ApproveQrSessionRequest {
string sessionId = 1;
string userId = 2;
}
message QrAuthPayload {
string accessToken = 1;
string refreshToken = 2;
string sessionId = 3;
bool pinVerified = 4;
string expiresAt = 5;
}
message UserIdRequest {
@@ -155,7 +177,27 @@ message RemoveFamilyMemberRequest {
message SendFamilyInviteRequest {
string requesterId = 1;
string groupId = 2;
string target = 3;
optional string target = 3;
optional string inviteeUserId = 4;
}
message SearchFamilyInviteUsersRequest {
string requesterId = 1;
string groupId = 2;
string query = 3;
}
message FamilyInviteUserCandidate {
string id = 1;
string displayName = 2;
optional string email = 3;
optional string phone = 4;
optional string username = 5;
bool hasAvatar = 6;
}
message SearchFamilyInviteUsersResponse {
repeated FamilyInviteUserCandidate users = 1;
}
message RespondFamilyInviteRequest {

View File

@@ -47,6 +47,7 @@ message PresignedUploadResponse {
string uploadUrl = 1;
string storageKey = 2;
string expiresAt = 3;
optional string uploadToken = 4;
}
message AvatarResponse {

View File

@@ -37,6 +37,7 @@ message UpdateProfileRequest {
optional string bio = 4;
optional int32 age = 5;
optional string gender = 6;
optional string birthDate = 7;
}
message UpdateContactsRequest {
@@ -62,6 +63,7 @@ message ProfileResponse {
optional string phone = 11;
optional string backupEmail = 12;
optional string backupPhone = 13;
optional string birthDate = 15;
}
message SoftDeleteProfileResponse {

View File

@@ -18,6 +18,10 @@ service SecurityService {
rpc ConnectLinkedAccount (ConnectLinkedAccountRequest) returns (LinkedAccount);
rpc DisconnectLinkedAccount (DisconnectLinkedAccountRequest) returns (MutationCountResponse);
rpc ListLinkedAccounts (UserSecurityRequest) returns (ListLinkedAccountsResponse);
rpc GetTotpStatus (UserSecurityRequest) returns (TotpStatusResponse);
rpc SetupTotp (UserSecurityRequest) returns (TotpSetupResponse);
rpc EnableTotp (TotpCodeRequest) returns (TotpStatusResponse);
rpc DisableTotp (TotpCodeRequest) returns (TotpStatusResponse);
}
service SettingsService {
@@ -36,6 +40,7 @@ message Empty {}
message UserSecurityRequest {
string userId = 1;
optional string exceptSessionId = 2;
}
message Device {
@@ -229,3 +234,17 @@ message TestMessagingDeliveryResponse {
string target = 3;
optional string message = 4;
}
message TotpStatusResponse {
bool isEnabled = 1;
}
message TotpSetupResponse {
string secret = 1;
string otpauthUrl = 2;
}
message TotpCodeRequest {
string userId = 1;
string code = 2;
}