diff --git a/.env.example b/.env.example index e91243e..3e46655 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api-gateway/src/controllers/advanced-auth.controller.ts b/apps/api-gateway/src/controllers/advanced-auth.controller.ts index e7fd904..6717b0a 100644 --- a/apps/api-gateway/src/controllers/advanced-auth.controller.ts +++ b/apps/api-gateway/src/controllers/advanced-auth.controller.ts @@ -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 }); + } } diff --git a/apps/api-gateway/src/controllers/auth.controller.ts b/apps/api-gateway/src/controllers/auth.controller.ts index f1b0ea5..006d83b 100644 --- a/apps/api-gateway/src/controllers/auth.controller.ts +++ b/apps/api-gateway/src/controllers/auth.controller.ts @@ -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 }) diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index 04cd372..cb5814b 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -1,130 +1,148 @@ -import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; -import { firstValueFrom } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { CoreGrpcService } from '../core-grpc.service'; -import { getAuthorizedUserId } from '../document-access'; -import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; - -@ApiTags('Семья') -@ApiBearerAuth() -@Controller('family') -export class FamilyController { - constructor( - private readonly core: CoreGrpcService, - private readonly jwt: JwtService - ) {} - - private async auth(authorization?: string) { - return getAuthorizedUserId(this.jwt, this.core, authorization); - } - - @Post('groups') - @ApiOperation({ summary: 'Создать семейную группу' }) - @ApiBody({ type: CreateFamilyGroupDto }) - async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) { - const userId = await this.auth(authorization); - if (dto.ownerId !== userId) { - throw new ForbiddenException('Можно создавать семью только от своего имени'); - } - return firstValueFrom(this.core.family.CreateFamilyGroup(dto)); - } - - @Get('users/:userId/groups') - @ApiOperation({ summary: 'Список семей пользователя' }) - async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { - const requesterId = await this.auth(authorization); - if (requesterId !== userId) { - throw new ForbiddenException('Можно просматривать только свои семьи'); - } - return firstValueFrom( - this.core.family.ListFamilyGroups({ userId }).pipe( - map((response) => { - const payload = response as { groups?: unknown[] }; - return { groups: payload.groups ?? [] }; - }) - ) - ); - } - - @Get('groups/:groupId') - @ApiOperation({ summary: 'Получить семейную группу' }) - async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId })); - } - - @Patch('groups/:groupId') - @ApiOperation({ summary: 'Обновить семейную группу' }) - async updateGroup( - @Headers('authorization') authorization: string | undefined, - @Param('groupId') groupId: string, - @Body() dto: UpdateFamilyGroupDto - ) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.UpdateFamilyGroup({ requesterId, groupId, name: dto.name })); - } - - @Delete('groups/:groupId') - @ApiOperation({ summary: 'Удалить семейную группу' }) - async deleteGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.DeleteFamilyGroup({ requesterId, groupId })); - } - - @Post('groups/:groupId/members') - @ApiOperation({ summary: 'Добавить участника семьи' }) - async addMember( - @Headers('authorization') authorization: string | undefined, - @Param('groupId') groupId: string, - @Body() dto: AddFamilyMemberDto - ) { - await this.auth(authorization); - return firstValueFrom(this.core.family.AddFamilyMember({ groupId, ...dto })); - } - - @Delete('members/:memberId') - @ApiOperation({ summary: 'Удалить участника семьи' }) - async removeMember(@Headers('authorization') authorization: string | undefined, @Param('memberId') memberId: string) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId })); - } - - @Post('groups/:groupId/invites') - @ApiOperation({ summary: 'Пригласить участника в семью' }) - async sendInvite( - @Headers('authorization') authorization: string | undefined, - @Param('groupId') groupId: string, - @Body() dto: SendFamilyInviteDto - ) { - const requesterId = await this.auth(authorization); - return firstValueFrom(this.core.family.SendFamilyInvite({ requesterId, groupId, target: dto.target })); - } - - @Get('invites') - @ApiOperation({ summary: 'Список приглашений' }) - async listInvites(@Headers('authorization') authorization: string | undefined) { - const userId = await this.auth(authorization); - return firstValueFrom( - this.core.family.ListFamilyInvites({ userId }).pipe( - map((response) => { - const payload = response as { invites?: unknown[] }; - return { invites: payload.invites ?? [] }; - }) - ) - ); - } - - @Post('invites/:inviteId/respond') - @ApiOperation({ summary: 'Принять или отклонить приглашение' }) - async respondInvite( - @Headers('authorization') authorization: string | undefined, - @Param('inviteId') inviteId: string, - @Body() dto: RespondFamilyInviteDto - ) { - const userId = await this.auth(authorization); - return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept })); - } -} - \ No newline at end of file +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'; +import { map } from 'rxjs/operators'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; + +@ApiTags('Семья') +@ApiBearerAuth() +@Controller('family') +export class FamilyController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async auth(authorization?: string) { + return getAuthorizedUserId(this.jwt, this.core, authorization); + } + + @Post('groups') + @ApiOperation({ summary: 'Создать семейную группу' }) + @ApiBody({ type: CreateFamilyGroupDto }) + async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) { + const userId = await this.auth(authorization); + if (dto.ownerId !== userId) { + throw new ForbiddenException('Можно создавать семью только от своего имени'); + } + return firstValueFrom(this.core.family.CreateFamilyGroup(dto)); + } + + @Get('users/:userId/groups') + @ApiOperation({ summary: 'Список семей пользователя' }) + async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + const requesterId = await this.auth(authorization); + if (requesterId !== userId) { + throw new ForbiddenException('Можно просматривать только свои семьи'); + } + return firstValueFrom( + this.core.family.ListFamilyGroups({ userId }).pipe( + map((response) => { + const payload = response as { groups?: unknown[] }; + return { groups: payload.groups ?? [] }; + }) + ) + ); + } + + @Get('groups/:groupId') + @ApiOperation({ summary: 'Получить семейную группу' }) + async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId })); + } + + @Patch('groups/:groupId') + @ApiOperation({ summary: 'Обновить семейную группу' }) + async updateGroup( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: UpdateFamilyGroupDto + ) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.UpdateFamilyGroup({ requesterId, groupId, name: dto.name })); + } + + @Delete('groups/:groupId') + @ApiOperation({ summary: 'Удалить семейную группу' }) + async deleteGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.DeleteFamilyGroup({ requesterId, groupId })); + } + + @Post('groups/:groupId/members') + @ApiOperation({ summary: 'Добавить участника семьи' }) + async addMember( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: AddFamilyMemberDto + ) { + await this.auth(authorization); + return firstValueFrom(this.core.family.AddFamilyMember({ groupId, ...dto })); + } + + @Delete('members/:memberId') + @ApiOperation({ summary: 'Удалить участника семьи' }) + async removeMember(@Headers('authorization') authorization: string | undefined, @Param('memberId') memberId: string) { + const requesterId = await this.auth(authorization); + 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( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @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') + @ApiOperation({ summary: 'Список приглашений' }) + async listInvites(@Headers('authorization') authorization: string | undefined) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.family.ListFamilyInvites({ userId }).pipe( + map((response) => { + const payload = response as { invites?: unknown[] }; + return { invites: payload.invites ?? [] }; + }) + ) + ); + } + + @Post('invites/:inviteId/respond') + @ApiOperation({ summary: 'Принять или отклонить приглашение' }) + async respondInvite( + @Headers('authorization') authorization: string | undefined, + @Param('inviteId') inviteId: string, + @Body() dto: RespondFamilyInviteDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept })); + } +} + diff --git a/apps/api-gateway/src/controllers/media.controller.ts b/apps/api-gateway/src/controllers/media.controller.ts index 5ac3c10..ce486c5 100644 --- a/apps/api-gateway/src/controllers/media.controller.ts +++ b/apps/api-gateway/src/controllers/media.controller.ts @@ -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 для загрузки изображения аватара.' }) diff --git a/apps/api-gateway/src/controllers/security.controller.ts b/apps/api-gateway/src/controllers/security.controller.ts index c2a88d7..9e48f5b 100644 --- a/apps/api-gateway/src/controllers/security.controller.ts +++ b/apps/api-gateway/src/controllers/security.controller.ts @@ -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 }); } } diff --git a/apps/api-gateway/src/dto/auth.dto.ts b/apps/api-gateway/src/dto/auth.dto.ts index 239b000..eb876b2 100644 --- a/apps/api-gateway/src/dto/auth.dto.ts +++ b/apps/api-gateway/src/dto/auth.dto.ts @@ -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 должен быть строкой' }) diff --git a/apps/api-gateway/src/dto/identity.dto.ts b/apps/api-gateway/src/dto/identity.dto.ts index c8876e9..5b04cf2 100644 --- a/apps/api-gateway/src/dto/identity.dto.ts +++ b/apps/api-gateway/src/dto/identity.dto.ts @@ -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 { diff --git a/apps/api-gateway/src/dto/profile.dto.ts b/apps/api-gateway/src/dto/profile.dto.ts index 393df5a..5927970 100644 --- a/apps/api-gateway/src/dto/profile.dto.ts +++ b/apps/api-gateway/src/dto/profile.dto.ts @@ -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 { diff --git a/apps/api-gateway/src/dto/security.dto.ts b/apps/api-gateway/src/dto/security.dto.ts index 0e5d529..3908475 100644 --- a/apps/api-gateway/src/dto/security.dto.ts +++ b/apps/api-gateway/src/dto/security.dto.ts @@ -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; +} diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index ac752ee..2da840e 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -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 } ] diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 2795f91..78bf1d4 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -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-код', diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 6b1a0fd..0d709b6 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -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() { + ) : null} {currentUser?.canViewUserDocuments ? ( diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index cc77a6a..107e452 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -1,364 +1,1231 @@ 'use client'; -import Link from 'next/link'; -import { FormEvent, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ChevronLeft, KeyRound, Mail, Network, Phone, ShieldQuestion, UserRound } from 'lucide-react'; -import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo'; -import { useAuth } from '@/components/id/auth-provider'; -import { usePublicSettings } from '@/components/id/public-settings-provider'; -import { useToast } from '@/components/id/toast-provider'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { OtpInput } from '@/components/ui/otp-input'; -import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { LoginMethod } from '@/lib/api'; -type Step = 'identify' | 'otp' | 'password' | 'pin' | 'ldap'; + +import Link from 'next/link'; + +import { FormEvent, useCallback, useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { ChevronLeft, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react'; + +import { QRCodeSVG } from 'qrcode.react'; + +import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo'; + +import { useAuth } from '@/components/id/auth-provider'; + +import { usePublicSettings } from '@/components/id/public-settings-provider'; + +import { useToast } from '@/components/id/toast-provider'; + +import { Button } from '@/components/ui/button'; + +import { Input } from '@/components/ui/input'; + +import { OtpInput } from '@/components/ui/otp-input'; + +import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import type { AuthTokens, LoginMethod, OtpChannel } from '@/lib/api'; + +import { createQrLoginSession, pollQrLoginSession } from '@/lib/api'; + + + +type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp'; + + const channelIcon: Record = { + email: Mail, + backupEmail: Mail, + phone: Phone, + backupPhone: Phone, + password: KeyRound + }; + + +const channelLabel: Record = { + + email: 'Почта', + + phone: 'Телефон', + + backupEmail: 'Резервная почта', + + backupPhone: 'Резервный телефон' + +}; + + + export default function LoginPage() { + const router = useRouter(); - const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, completePin } = useAuth(); + + const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth(); + const { showToast } = useToast(); + const { ldapEnabled, ldapUseLdaps } = usePublicSettings(); + + const [authTab, setAuthTab] = useState<'email' | 'phone'>('email'); + const [email, setEmail] = useState(''); + const [phoneNumber, setPhoneNumber] = useState(''); + const [country, setCountry] = useState(phoneCountries[0]); + + const [recipient, setRecipient] = useState(''); + const [maskedTarget, setMaskedTarget] = useState(''); + const [otp, setOtp] = useState(''); + + const [totpCode, setTotpCode] = useState(''); + + const [totpChallengeToken, setTotpChallengeToken] = useState(null); + const [password, setPassword] = useState(''); + const [ldapUsername, setLdapUsername] = useState(''); + const [ldapPassword, setLdapPassword] = useState(''); + const [pin, setPin] = useState(''); + const [pendingSessionId, setPendingSessionId] = useState(null); + const [methods, setMethods] = useState([]); + + const [otpChannels, setOtpChannels] = useState([]); + const [showMethods, setShowMethods] = useState(false); + const [step, setStep] = useState('identify'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [qrPayload, setQrPayload] = useState(null); + + const [qrSessionId, setQrSessionId] = useState(null); + + const [qrExpiresAt, setQrExpiresAt] = useState(null); + + const [qrLoading, setQrLoading] = useState(false); + + + function getIdentifier() { + return authTab === 'email' ? email.trim() : toE164(country, phoneNumber); + } + + + function beginTotpStep(challengeToken: string) { + + setTotpChallengeToken(challengeToken); + + setTotpCode(''); + + setStep('totp'); + + } + + + function finishAuth(pinVerified: boolean, sessionId: string) { + if (!pinVerified) { + setPendingSessionId(sessionId); + setStep('pin'); + } else { + router.push('/'); + } + } + + + function finishQrAuth(auth: AuthTokens) { + + const resolved = applyLoginAuth(auth); + + finishAuth(resolved.pinVerified, resolved.sessionId); + + } + + + + async function requestLoginOtp(channel?: string, recipientOverride?: string) { + + setIsSubmitting(true); + + const loginRecipient = recipientOverride ?? recipient; + + try { + + const masked = await sendLoginOtp(loginRecipient, channel); + + setMaskedTarget(masked); + + setOtp(''); + + setShowMethods(false); + + setStep('otp'); + + } catch (error) { + + showToast(error instanceof Error ? error.message : 'Не удалось отправить код'); + + } finally { + + setIsSubmitting(false); + + } + + } + + + + function openOtpDelivery() { + + if (otpChannels.length > 1) { + + setShowMethods(false); + + setStep('otpChannel'); + + return; + + } + + void requestLoginOtp(otpChannels[0]?.channel); + + } + + + + const startQrLogin = useCallback(async () => { + + setQrLoading(true); + + try { + + const deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser'; + + const session = await createQrLoginSession(deviceName); + + setQrSessionId(session.sessionId); + + setQrPayload(session.qrPayload ?? null); + + setQrExpiresAt(session.expiresAt); + + setStep('qr'); + + } catch (error) { + + showToast(error instanceof Error ? error.message : 'Не удалось создать QR-сессию'); + + } finally { + + setQrLoading(false); + + } + + }, [showToast]); + + + + useEffect(() => { + + if (step !== 'qr' || !qrSessionId) return; + + let cancelled = false; + + + + const poll = async () => { + + try { + + const session = await pollQrLoginSession(qrSessionId); + + if (cancelled) return; + + if (session.status === 'EXPIRED') { + + showToast('QR-код истёк, создайте новый'); + + setStep('identify'); + + return; + + } + + if (session.status === 'APPROVED' && session.auth?.user) { + + finishQrAuth({ + + accessToken: session.auth.accessToken, + + refreshToken: session.auth.refreshToken, + + sessionId: session.auth.sessionId, + + pinVerified: session.auth.pinVerified, + + expiresAt: session.auth.expiresAt, + + user: session.auth.user + + }); + + } + + } catch { + + if (!cancelled) { + + showToast('Не удалось проверить QR-сессию'); + + } + + } + + }; + + + + void poll(); + + const timer = window.setInterval(() => void poll(), 2000); + + return () => { + + cancelled = true; + + window.clearInterval(timer); + + }; + + }, [applyLoginAuth, qrSessionId, showToast, step]); + + + async function handleIdentify(event: FormEvent) { + event.preventDefault(); + setIsSubmitting(true); + try { + const identifier = getIdentifier(); + + if (authTab === 'phone') { + const digits = phoneNumber.replace(/\D/g, ''); + if (digits.length < country.maxDigits) { + showToast('Введите номер телефона полностью'); + return; + } + } else if (!identifier) { + showToast('Укажите почту'); + return; + } + setRecipient(identifier); + const result = await identifyLogin(identifier); + setMethods(result.methods ?? []); - const masked = await sendLoginOtp(identifier); - setMaskedTarget(masked); - setOtp(''); - setStep('otp'); + + setOtpChannels(result.otpChannels ?? []); + + + + if (result.isTotpEnabled) { + + const challengeToken = await beginTotpLogin(identifier); + + beginTotpStep(challengeToken); + + return; + + } + + + + const channels = result.otpChannels ?? []; + + if (channels.length > 1) { + + setStep('otpChannel'); + + return; + + } + + if (channels.length === 1) { + + await requestLoginOtp(channels[0].channel, identifier); + + return; + + } + + if (result.hasPassword) { + + setStep('password'); + + return; + + } + + showToast('Для этого аккаунта не настроены каналы для SMS или email-кода'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось продолжить вход'); + } finally { + setIsSubmitting(false); + } + } + + async function verifyOtp(code: string) { + setIsSubmitting(true); + try { + const response = await verifyLoginOtp(recipient, code); - if (response.auth) { - finishAuth(response.auth.pinVerified, response.auth.sessionId); - } else { - showToast('Не удалось завершить вход'); + + if (response.requiresTotp && response.totpChallengeToken) { + beginTotpStep(response.totpChallengeToken); + return; } + + if (response.auth) { + + finishAuth(response.auth.pinVerified, response.auth.sessionId); + + } else { + + showToast('Не удалось завершить вход'); + + } + } catch (error) { + setOtp(''); + showToast(error instanceof Error ? error.message : 'Неверный код'); + } finally { + setIsSubmitting(false); + } + } + + + async function verifyTotp(code: string) { + + if (!totpChallengeToken) return; + + setIsSubmitting(true); + + try { + + const auth = await verifyTotpLogin(totpChallengeToken, code); + + setTotpChallengeToken(null); + + finishAuth(auth.pinVerified, auth.sessionId); + + } catch (error) { + + setTotpCode(''); + + showToast(error instanceof Error ? error.message : 'Неверный код аутентификатора'); + + } finally { + + setIsSubmitting(false); + + } + + } + + + async function handlePasswordSubmit(event: FormEvent) { + event.preventDefault(); + setIsSubmitting(true); + try { + const auth = await loginWithPassword(recipient, password); + finishAuth(auth.pinVerified, auth.sessionId); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Неверный пароль'); + } finally { + setIsSubmitting(false); + } + } + + async function chooseMethod(method: LoginMethod) { + setShowMethods(false); + if (method.kind === 'password') { + setPassword(''); + setStep('password'); + return; + } - setIsSubmitting(true); - try { - const masked = await sendLoginOtp(recipient, method.channel); - setMaskedTarget(masked); - setOtp(''); - setStep('otp'); - } catch (error) { - showToast(error instanceof Error ? error.message : 'Не удалось отправить код'); - } finally { - setIsSubmitting(false); - } + + await requestLoginOtp(method.channel); + } + + async function handleLdapSubmit(event: FormEvent) { + event.preventDefault(); + setIsSubmitting(true); + try { + const auth = await loginWithLdap(ldapUsername.trim(), ldapPassword); + finishAuth(auth.pinVerified, auth.sessionId); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось выполнить LDAP-вход'); + } finally { + setIsSubmitting(false); + } + } + + async function handlePinSubmit(event: FormEvent) { + event.preventDefault(); + if (!pendingSessionId) return; + setIsSubmitting(true); + try { + await completePin(pendingSessionId, pin); + router.push('/'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код'); + } finally { + setIsSubmitting(false); + } + } + + function resetToIdentify() { + setStep('identify'); + setShowMethods(false); + setOtp(''); + + setTotpCode(''); + + setTotpChallengeToken(null); + + setOtpChannels([]); + setPassword(''); + setLdapUsername(''); + setLdapPassword(''); + + setQrPayload(null); + + setQrSessionId(null); + + setQrExpiresAt(null); + } + + + function renderAlternativeMethods(includeOtpRequest: boolean) { + + const hasAlternatives = includeOtpRequest || methods.length > 0; + + if (!hasAlternatives) return null; + + + + return ( + +
+ + + + {showMethods ? ( + +
+ + {includeOtpRequest && otpChannels.length ? ( + + + + ) : null} + + {methods.map((method) => { + + const Icon = channelIcon[method.channel] ?? KeyRound; + + return ( + + + + ); + + })} + +
+ + ) : null} + +
+ + ); + + } + + + return ( +
+
+
+ +

Войдите с ID

+
+ + {step !== 'identify' ? ( + + ) : null} + + {step === 'identify' ? ( +
+ setAuthTab(value as 'email' | 'phone')} className="w-full"> + + Почта + Телефон + + + setEmail(event.target.value)} + required={authTab === 'email'} + /> + + + + + + + + + {ldapEnabled ? ( + + ) : null} +
+ ) : null} + + {step === 'ldap' ? ( +
+
+
+ +
+
+

Корпоративный вход

+

{ldapUseLdaps ? 'LDAPS' : 'LDAP'}

+
+
+ setLdapUsername(event.target.value)} + autoComplete="username" + required + /> + setLdapPassword(event.target.value)} + autoComplete="current-password" + required + /> + +
+ ) : null} + + + {step === 'otpChannel' ? ( + +
+ +
+ +
+ + + +
+ +
+ +

Куда отправить код

+ +

Выберите канал доставки

+ +
+ +
+ +
+ + {otpChannels.map((channel) => { + + const Icon = channelIcon[channel.channel] ?? Mail; + + return ( + + + + ); + + })} + +
+ + {totpChallengeToken ? ( + + + + ) : null} + +
+ + ) : null} + + + {step === 'otp' ? ( +
+
+
+ +
+
+

Код отправлен на

+

{maskedTarget || recipient}

+
+
+ + {isSubmitting ?

Проверяем код...

: null} - {methods.length ? ( -
- - {showMethods ? ( -
- {methods.map((method) => { - const Icon = channelIcon[method.channel] ?? KeyRound; - return ( - - ); - })} -
- ) : null} -
+ {renderAlternativeMethods(false)} + + {totpChallengeToken ? ( + + + ) : null} +
+ ) : null} + + + {step === 'totp' ? ( + +
+ +
+ +
+ + + +
+ +
+ +

Вход в аккаунт

+ +

Код из приложения-аутентификатора

+ +
+ +
+ + + + {isSubmitting ?

Проверяем код...

: null} + + {renderAlternativeMethods(true)} + +
+ + ) : null} + + + {step === 'password' ? ( +
+
+
+ +
+
+

Вход в аккаунт

+

{recipient}

+
+
+ setPassword(event.target.value)} + required + /> + - + + {totpChallengeToken ? ( + + + + ) : null} +
+ ) : null} + + + {step === 'qr' && qrPayload ? ( + +
+ +
+ + + +
+ +

+ + Откройте мобильное приложение ID, отсканируйте код и подтвердите вход на этом устройстве. + +

+ + {qrExpiresAt ? ( + +

Код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))}

+ + ) : null} + + + +
+ + ) : null} + + + {step === 'pin' && pendingSessionId ? ( +
+ setPin(event.target.value)} + required + minLength={4} + maxLength={6} + /> + +
+ ) : null} + + {step === 'identify' ? ( +
+ +
+ ) : null} + + +
+
+ ); + } + + diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index 3b6e60c..f4de9d2 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -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(); + const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]); + const [phoneDigits, setPhoneDigits] = useState(''); + const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]); + const [backupPhoneDigits, setBackupPhoneDigits] = useState(''); const [documents, setDocuments] = useState([]); const [isSaving, setIsSaving] = useState(false); const [activeDocumentType, setActiveDocumentType] = useState(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(`/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 = {}; + 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() {

Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.

setDisplayName(event.target.value)} /> - + setEmail(event.target.value)} /> setBackupEmail(event.target.value)} /> - setPhone(event.target.value)} /> - setBackupPhone(event.target.value)} /> + { + setPhoneDigits(digits); + setPhone(digits ? toE164(phoneCountry, digits) : ''); + }} + required={false} + /> + { + setBackupPhoneDigits(digits); + setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : ''); + }} + required={false} + />
+

Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства — через меню профиля.

+
+

Дополнительная защита

+
+ +
+
+

Способ входа в профиль

@@ -248,9 +328,45 @@ export default function SecurityPage() {
- (open ? null : setDialogMode(null))}> + { + if (!open) { + setDialogMode(null); + if (dialogMode !== 'totpSetup' || totpEnabled) { + setTotpSetup(null); + } + setTotpCode(''); + } + }}> - {dialogMode ? ( + {dialogMode === 'totpSetup' && totpSetup ? ( + <> + + Подключить аутентификатор + +

Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.

+
+ +
+

+ Ключ: {totpSetup.secret} +

+ void submitTotpSetup(code)} disabled={isDialogSaving} /> + {isDialogSaving ?

Проверяем код...

: null} + + ) : null} + + {dialogMode === 'totpDisable' ? ( + <> + + Отключить аутентификатор + +

Введите код из приложения, чтобы отключить двухфакторную аутентификацию.

+ void submitTotpDisable(code)} disabled={isDialogSaving} /> + {isDialogSaving ?

Проверяем код...

: null} + + ) : null} + + {dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? ( <> {dialogConfig[dialogMode].title} diff --git a/apps/frontend/components/documents/document-form-dialog.tsx b/apps/frontend/components/documents/document-form-dialog.tsx index 7059a8d..fced0d8 100644 --- a/apps/frontend/components/documents/document-form-dialog.tsx +++ b/apps/frontend/components/documents/document-form-dialog.tsx @@ -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; diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index 55ca609..ab574d8 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -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([]); + const [selectedInviteUser, setSelectedInviteUser] = useState(null); + const [inviteSearching, setInviteSearching] = useState(false); const [renameValue, setRenameValue] = useState(''); const [newRoomName, setNewRoomName] = useState(''); const [selectedMembers, setSelectedMembers] = useState([]); @@ -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 = ''; }} /> - + { + setInviteOpen(open); + if (!open) { + setInviteQuery(''); + setInviteResults([]); + setSelectedInviteUser(null); + } + }}> Пригласить в семью - setInviteTarget(event.target.value)} /> - + {selectedInviteUser ? ( +
+

{selectedInviteUser.displayName}

+

+ {selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'} +

+ +
+ ) : ( + <> + setInviteQuery(event.target.value)} + /> +
+ {inviteSearching ? ( +
+ + Ищем пользователей... +
+ ) : inviteQuery.trim().length < 2 ? ( +

Введите минимум 2 символа для поиска

+ ) : inviteResults.length ? ( + inviteResults.map((candidate) => ( + + )) + ) : ( +

Пользователи не найдены

+ )} +
+ + )} +
diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index f0fc19f..fefb673 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -39,7 +39,10 @@ interface AuthContextValue { verifyLoginOtp: (recipient: string, code: string) => Promise; loginWithPassword: (login: string, password: string) => Promise; loginWithLdap: (username: string, password: string) => Promise; + beginTotpLogin: (recipient: string) => Promise; + verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise; completePin: (sessionId: string, pin: string) => Promise; + applyLoginAuth: (auth: AuthTokens) => AuthTokens; register: (data: { displayName: string; login: string; password: string }) => Promise; refreshProfile: () => Promise; 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('/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('/auth/pin/verify', { @@ -452,7 +499,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { verifyLoginOtp, loginWithPassword, loginWithLdap, + beginTotpLogin, + verifyTotpLogin, completePin, + applyLoginAuth, register, refreshProfile, logout diff --git a/apps/frontend/components/id/avatar-upload.tsx b/apps/frontend/components/id/avatar-upload.tsx index 1660c6a..fb76e7c 100644 --- a/apps/frontend/components/id/avatar-upload.tsx +++ b/apps/frontend/components/id/avatar-upload.tsx @@ -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', diff --git a/apps/frontend/components/ui/button.tsx b/apps/frontend/components/ui/button.tsx index fe4065c..c3f9003 100644 --- a/apps/frontend/components/ui/button.tsx +++ b/apps/frontend/components/ui/button.tsx @@ -28,6 +28,8 @@ const buttonVariants = cva( } ); +export { buttonVariants }; + export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } diff --git a/apps/frontend/components/ui/calendar.tsx b/apps/frontend/components/ui/calendar.tsx new file mode 100644 index 0000000..e374842 --- /dev/null +++ b/apps/frontend/components/ui/calendar.tsx @@ -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 ( + + + + 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()} + /> + + + ); +} diff --git a/apps/frontend/components/ui/otp-input.tsx b/apps/frontend/components/ui/otp-input.tsx index 483c65d..cb96ea6 100644 --- a/apps/frontend/components/ui/otp-input.tsx +++ b/apps/frontend/components/ui/otp-input.tsx @@ -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>([]); 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 + )} /> ))} diff --git a/apps/frontend/components/ui/phone-input.tsx b/apps/frontend/components/ui/phone-input.tsx index a4861eb..286c178 100644 --- a/apps/frontend/components/ui/phone-input.tsx +++ b/apps/frontend/components/ui/phone-input.tsx @@ -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 (
- @@ -122,16 +159,16 @@ export function PhoneInput({ onValueChange(event.target.value.replace(/\D/g, '').slice(0, country.maxDigits))} - required + required={required} /> {value ? ( - ) : null} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index ace7de6..36e3cdb 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -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('/auth/advanced/qr/session', { + method: 'POST', + body: JSON.stringify({ + deviceName, + fingerprint: getDeviceFingerprint(), + deviceType: 'WEB' + }) + }); +} + +export async function pollQrLoginSession(sessionId: string) { + return apiFetch(`/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(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); } -export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) { - return apiFetch(`/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(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); } export async function fetchFamilyInvites(token?: string | null) { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 80d0236..a31dad2 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/ldap-auth/dns.go b/apps/ldap-auth/dns.go new file mode 100644 index 0000000..681de9b --- /dev/null +++ b/apps/ldap-auth/dns.go @@ -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 "" +} diff --git a/apps/ldap-auth/main.go b/apps/ldap-auth/main.go index 51e248c..0d49313 100644 --- a/apps/ldap-auth/main.go +++ b/apps/ldap-auth/main.go @@ -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: и LDAP_DNS_SERVERS=, затем пересоздайте ldap-auth: %w", lastErr) } return nil, fmt.Errorf("не удалось подключиться к LDAP: %w", lastErr) } diff --git a/apps/sso-core/package.json b/apps/sso-core/package.json index bef8476..40fc082 100644 --- a/apps/sso-core/package.json +++ b/apps/sso-core/package.json @@ -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", diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index 0e78cc8..45cdf22 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -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 { diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index 272c5f2..b5f7129 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -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, diff --git a/apps/sso-core/src/domain/advanced-auth.service.ts b/apps/sso-core/src/domain/advanced-auth.service.ts index 8aab1ac..744af53 100644 --- a/apps/sso-core/src/domain/advanced-auth.service.ts +++ b/apps/sso-core/src/domain/advanced-auth.service.ts @@ -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>; + }; + 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('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 + } }; } diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 9cbdadc..d1bb7d8 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -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') diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index e94f470..5a5cc54 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -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; @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 { @@ -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('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) { + 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 { @@ -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 { + 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 { + 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('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) { 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 } }); } diff --git a/apps/sso-core/src/domain/dto.ts b/apps/sso-core/src/domain/dto.ts index 5d99b92..51f9f27 100644 --- a/apps/sso-core/src/domain/dto.ts +++ b/apps/sso-core/src/domain/dto.ts @@ -23,6 +23,8 @@ export interface AuthTokens { pinVerified: boolean; sessionId: string; user: PublicUser; + requiresTotp?: boolean; + totpChallengeToken?: string; } export interface PublicUser { diff --git a/apps/sso-core/src/domain/family.service.ts b/apps/sso-core/src/domain/family.service.ts index 2c15a13..318ac5c 100644 --- a/apps/sso-core/src/domain/family.service.ts +++ b/apps/sso-core/src/domain/family.service.ts @@ -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' } } + ] } - }); - } diff --git a/apps/sso-core/src/domain/media.service.ts b/apps/sso-core/src/domain/media.service.ts index e6b354e..f2990d9 100644 --- a/apps/sso-core/src/domain/media.service.ts +++ b/apps/sso-core/src/domain/media.service.ts @@ -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('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('JWT_ACCESS_SECRET'), + expiresIn: UPLOAD_TTL_SECONDS, + issuer: 'id.lendry.ru' + } + ); + const publicApiUrl = this.config.get('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, diff --git a/apps/sso-core/src/domain/notifications.service.ts b/apps/sso-core/src/domain/notifications.service.ts index 52d903c..bb8b9e2 100644 --- a/apps/sso-core/src/domain/notifications.service.ts +++ b/apps/sso-core/src/domain/notifications.service.ts @@ -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' } diff --git a/apps/sso-core/src/domain/otp.service.ts b/apps/sso-core/src/domain/otp.service.ts index 7937d14..b5a08dc 100644 --- a/apps/sso-core/src/domain/otp.service.ts +++ b/apps/sso-core/src/domain/otp.service.ts @@ -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 'Подтверждение действия'; + } + } } diff --git a/apps/sso-core/src/domain/profile.service.ts b/apps/sso-core/src/domain/profile.service.ts index 70fce77..2992a32 100644 --- a/apps/sso-core/src/domain/profile.service.ts +++ b/apps/sso-core/src/domain/profile.service.ts @@ -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; + } + } diff --git a/apps/sso-core/src/domain/security.service.ts b/apps/sso-core/src/domain/security.service.ts index 666872b..d9355f2 100644 --- a/apps/sso-core/src/domain/security.service.ts +++ b/apps/sso-core/src/domain/security.service.ts @@ -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 } }); } diff --git a/apps/sso-core/src/domain/totp.service.ts b/apps/sso-core/src/domain/totp.service.ts new file mode 100644 index 0000000..3bff615 --- /dev/null +++ b/apps/sso-core/src/domain/totp.service.ts @@ -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('Пользователь не найден'); + } + } +} diff --git a/apps/sso-core/src/infra/phone.util.ts b/apps/sso-core/src/infra/phone.util.ts new file mode 100644 index 0000000..bfaf309 --- /dev/null +++ b/apps/sso-core/src/infra/phone.util.ts @@ -0,0 +1,39 @@ +export function phoneLookupVariants(value: string): string[] { + const trimmed = value.trim(); + const digits = trimmed.replace(/\D/g, ''); + const variants = new Set([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}`; +} diff --git a/docker-compose.yml b/docker-compose.yml index f793082..a8e292f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/install.sh b/install.sh index 631b673..5f2b019 100644 --- a/install.sh +++ b/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 } diff --git a/package-lock.json b/package-lock.json index c34a40c..6b8d0fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/shared/proto/auth.proto b/shared/proto/auth.proto index e93ed74..5020e06 100644 --- a/shared/proto/auth.proto +++ b/shared/proto/auth.proto @@ -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 { diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index b3ad28e..c870a30 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -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 { diff --git a/shared/proto/media.proto b/shared/proto/media.proto index e9f8716..4e6b18e 100644 --- a/shared/proto/media.proto +++ b/shared/proto/media.proto @@ -47,6 +47,7 @@ message PresignedUploadResponse { string uploadUrl = 1; string storageKey = 2; string expiresAt = 3; + optional string uploadToken = 4; } message AvatarResponse { diff --git a/shared/proto/profile.proto b/shared/proto/profile.proto index 1c9c203..547650f 100644 --- a/shared/proto/profile.proto +++ b/shared/proto/profile.proto @@ -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 { diff --git a/shared/proto/security.proto b/shared/proto/security.proto index 9f621e7..881ab5f 100644 --- a/shared/proto/security.proto +++ b/shared/proto/security.proto @@ -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; +}