more fix and update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 для загрузки изображения аватара.' })
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 должен быть строкой' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -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-код',
|
||||
|
||||
@@ -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
@@ -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 ? 'Сохраняем...' : 'Обновить профиль'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -28,6 +28,8 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
export { buttonVariants };
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
79
apps/frontend/components/ui/calendar.tsx
Normal file
79
apps/frontend/components/ui/calendar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
apps/frontend/components/ui/date-picker.tsx
Normal file
59
apps/frontend/components/ui/date-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
131
apps/ldap-auth/dns.go
Normal 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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface AuthTokens {
|
||||
pinVerified: boolean;
|
||||
sessionId: string;
|
||||
user: PublicUser;
|
||||
requiresTotp?: boolean;
|
||||
totpChallengeToken?: string;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
|
||||
@@ -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' } }
|
||||
]
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 'Подтверждение действия';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
163
apps/sso-core/src/domain/totp.service.ts
Normal file
163
apps/sso-core/src/domain/totp.service.ts
Normal 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('Пользователь не найден');
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/sso-core/src/infra/phone.util.ts
Normal file
39
apps/sso-core/src/infra/phone.util.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
49
install.sh
49
install.sh
@@ -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
125
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,6 +47,7 @@ message PresignedUploadResponse {
|
||||
string uploadUrl = 1;
|
||||
string storageKey = 2;
|
||||
string expiresAt = 3;
|
||||
optional string uploadToken = 4;
|
||||
}
|
||||
|
||||
message AvatarResponse {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user