first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'node:path';
import { AdminController } from './controllers/admin.controller';
import { AuthController } from './controllers/auth.controller';
import { RbacController } from './controllers/rbac.controller';
import { SecurityController } from './controllers/security.controller';
import { SettingsController } from './controllers/settings.controller';
import { HealthController } from './controllers/health.controller';
import { PublicSettingsController } from './controllers/public-settings.controller';
import { ProfileController } from './controllers/profile.controller';
import { DocumentsController } from './controllers/documents.controller';
import { AddressesController } from './controllers/addresses.controller';
import { OAuthController } from './controllers/oauth.controller';
import { AdvancedAuthController } from './controllers/advanced-auth.controller';
import { FamilyController } from './controllers/family.controller';
import { ChatController } from './controllers/chat.controller';
import { NotificationsController } from './controllers/notifications.controller';
import { MediaController } from './controllers/media.controller';
import { CoreGrpcService } from './core-grpc.service';
import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({}),
ClientsModule.registerAsync([
{
name: 'SSO_CORE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
protoPath: [
join(__dirname, '../../../shared/proto/auth.proto'),
join(__dirname, '../../../shared/proto/admin.proto'),
join(__dirname, '../../../shared/proto/rbac.proto'),
join(__dirname, '../../../shared/proto/security.proto'),
join(__dirname, '../../../shared/proto/profile.proto'),
join(__dirname, '../../../shared/proto/documents.proto'),
join(__dirname, '../../../shared/proto/addresses.proto'),
join(__dirname, '../../../shared/proto/identity.proto'),
join(__dirname, '../../../shared/proto/media.proto'),
join(__dirname, '../../../shared/proto/notifications.proto'),
join(__dirname, '../../../shared/proto/chat.proto')
],
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
}
})
}
])
],
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
providers: [CoreGrpcService, AdminGuard, SuperAdminGuard]
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
import { UnauthorizedException } from '@nestjs/common';
export function extractBearerToken(authorization?: string): string {
if (!authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException('Не передан токен доступа');
}
const token = authorization.slice('Bearer '.length).trim();
if (!token) {
throw new UnauthorizedException('Не передан токен доступа');
}
return token;
}

View File

@@ -0,0 +1,68 @@
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, 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 { UpsertAddressDto } from '../dto/addresses.dto';
@ApiTags('Адреса')
@ApiBearerAuth()
@Controller('addresses/users/:userId')
export class AddressesController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно управлять только своими адресами');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Список адресов пользователя' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(
this.core.addresses.ListAddresses({ userId }).pipe(
map((response) => {
const payload = response as { addresses?: unknown[] };
return { addresses: payload.addresses ?? [] };
})
)
);
}
@Post()
@ApiOperation({ summary: 'Создать или обновить адрес' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpsertAddressDto })
async upsert(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: UpsertAddressDto
) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(this.core.addresses.UpsertAddress({ userId, ...dto }));
}
@Delete(':addressId')
@ApiOperation({ summary: 'Удалить адрес' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'addressId', description: 'ID адреса' })
@ApiResponse({ status: 200, description: 'Адрес удалён' })
async delete(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('addressId') addressId: string
) {
await this.assertSelfAccess(authorization, userId);
return firstValueFrom(this.core.addresses.DeleteAddress({ userId, addressId }));
}
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
@ApiTags('Администрирование')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/users')
export class AdminController {
constructor(private readonly core: CoreGrpcService) {}
@Get()
@ApiOperation({ summary: 'Список пользователей', description: 'Возвращает пользователей для административной таблицы.' })
listUsers(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
if (!admin.canViewUsers && !admin.canManageUsers) {
throw new ForbiddenException('Недостаточно прав для просмотра пользователей');
}
return this.core.admin.ListUsers(query).pipe(
map((response) => {
const payload = response as { users?: Array<{ roles?: string[] }> };
return {
users: (payload.users ?? []).map((user) => ({
...user,
roles: user.roles ?? []
}))
};
})
);
}
@Patch(':userId')
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateUserDto })
updateUser(@Param('userId') userId: string, @Body() dto: UpdateUserDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.UpdateUserProfile({ userId, ...dto });
}
@Post(':userId/reset-password')
@ApiOperation({ summary: 'Сбросить пароль', description: 'Назначает пользователю новый пароль.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.ResetPassword({ userId, newPassword: dto.newPassword });
}
@Post(':userId/suspend')
@ApiOperation({ summary: 'Заблокировать пользователя', description: 'Переводит пользователя в статус SUSPENDED и отзывает активные сессии.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
suspend(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageUsers');
return this.core.admin.SuspendUser({ userId });
}
@Patch(':userId/super-admin')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Выдать или снять права супер-администратора', description: 'Доступно только супер-администратору.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: SetSuperAdminDto })
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
}
}

View File

@@ -0,0 +1,42 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
@ApiTags('Биометрия и QR-вход')
@Controller('auth/advanced')
export class AdvancedAuthController {
constructor(private readonly core: CoreGrpcService) {}
@Post('webauthn/register/challenge')
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
@ApiBody({ type: WebAuthnDto })
@ApiResponse({ status: 201, description: 'Challenge создан' })
webAuthnRegister(@Body() dto: WebAuthnDto) {
return this.core.advancedAuth.CreateWebAuthnRegistrationChallenge(dto);
}
@Post('webauthn/login/challenge')
@ApiOperation({ summary: 'Challenge входа WebAuthn', description: 'Создает challenge для входа по лицу или отпечатку.' })
@ApiBody({ type: WebAuthnDto })
@ApiResponse({ status: 201, description: 'Challenge создан' })
webAuthnLogin(@Body() dto: WebAuthnDto) {
return this.core.advancedAuth.CreateWebAuthnLoginChallenge(dto);
}
@Post('qr/session')
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
@ApiBody({ type: QrSessionDto })
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
createQr(@Body() dto: QrSessionDto) {
return this.core.advancedAuth.CreateQrSession(dto);
}
@Get('qr/session/:sessionId')
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
pollQr(@Param('sessionId') sessionId: string) {
return this.core.advancedAuth.PollQrSession({ sessionId });
}
}

View File

@@ -0,0 +1,129 @@
import { Body, Controller, Get, Headers, 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 { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
@ApiTags('Аутентификация')
@Controller('auth')
export class AuthController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post('register')
@ApiOperation({ summary: 'Регистрация пользователя', description: 'Создает пользователя. Первый пользователь безопасно получает права super admin.' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) {
return this.core.auth.Register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) {
return this.core.auth.Login(dto);
}
@Post('identify')
@ApiOperation({ summary: 'Проверить способ входа', description: 'Identifier-first шаг: проверяет, существует ли пользователь, задан ли пароль и включен ли PIN.' })
@ApiBody({ type: IdentifyDto })
identify(@Body() dto: IdentifyDto) {
return this.core.auth.Identify(dto);
}
@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 });
}
@Post('otp/verify')
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
@ApiBody({ type: PasswordlessVerifyDto })
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
return this.core.auth.VerifyOtp(dto);
}
@Post('login/password')
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
@ApiBody({ type: PasswordLoginDto })
loginWithPassword(@Body() dto: PasswordLoginDto) {
return this.core.auth.LoginWithPassword(dto);
}
@Post('ldap/login')
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
@ApiBody({ type: LdapLoginDto })
loginWithLdap(@Body() dto: LdapLoginDto) {
return this.core.auth.LoginWithLdap(dto);
}
@Post('pin/verify')
@ApiOperation({ summary: 'Подтвердить PIN-код', description: 'Разблокирует временную сессию и выдает JWT с pinVerified=true.' })
@ApiBody({ type: VerifyPinDto })
verifyPin(@Body() dto: VerifyPinDto) {
return this.core.auth.VerifyPin(dto);
}
@Post('refresh')
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
@ApiBody({ type: RefreshSessionDto })
refresh(@Body() dto: RefreshSessionDto) {
return this.core.auth.RefreshSession(dto);
}
@Get('session')
@ApiBearerAuth()
@ApiOperation({
summary: 'Состояние текущей сессии',
description: 'Возвращает профиль и флаг PIN-блокировки. Не выбрасывает пользователя из аккаунта при заблокированном PIN.'
})
async session(@Headers('authorization') authorization?: string) {
const payload = await verifyAccessToken(this.jwt, authorization);
if (!payload.sessionId) {
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
return { requiresPin: false, sessionId: null, pinVerified: true, user };
}
const validation = (await firstValueFrom(
this.core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: false
})
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
if (!validation.requiresPin) {
await firstValueFrom(
this.core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: true
})
);
}
return {
requiresPin: validation.requiresPin,
sessionId: validation.sessionId,
pinVerified: validation.pinVerified,
user
};
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Текущий пользователь', description: 'Возвращает профиль пользователя по access token.' })
async me(@Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
}
}

View File

@@ -0,0 +1,142 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiOperation, 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 {
CreateChatRoomDto,
SendChatMessageDto,
SetRoomNotificationsMutedDto,
UpdateChatRoomDto,
VotePollDto
} from '../dto/chat.dto';
@ApiTags('Чат')
@ApiBearerAuth()
@Controller('chat')
export class ChatController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async auth(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Get('groups/:groupId/rooms')
@ApiOperation({ summary: 'Список чатов семьи' })
async listRooms(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.ListRooms({ userId, groupId }).pipe(
map((response) => {
const payload = response as { rooms?: unknown[] };
return { rooms: payload.rooms ?? [] };
})
)
);
}
@Post('groups/:groupId/rooms')
@ApiOperation({ summary: 'Создать групповой чат' })
async createRoom(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: CreateChatRoomDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.CreateRoom({ userId, groupId, name: dto.name, memberUserIds: dto.memberUserIds ?? [] })
);
}
@Patch('rooms/:roomId')
@ApiOperation({ summary: 'Настройки чата' })
async updateRoom(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: UpdateChatRoomDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.UpdateRoomSettings({
userId,
roomId,
name: dto.name,
notificationsMuted: dto.notificationsMuted
})
);
}
@Get('rooms/:roomId/messages')
@ApiOperation({ summary: 'Сообщения чата' })
async listMessages(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Query('beforeMessageId') beforeMessageId?: string,
@Query('limit') limit?: string
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.ListMessages({
userId,
roomId,
beforeMessageId,
limit: limit ? Number(limit) : 50
}).pipe(
map((response) => {
const payload = response as { messages?: unknown[] };
return { messages: payload.messages ?? [] };
})
)
);
}
@Post('rooms/:roomId/messages')
@ApiOperation({ summary: 'Отправить сообщение' })
async sendMessage(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: SendChatMessageDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(
this.core.chat.SendMessage({
userId,
roomId,
type: dto.type,
content: dto.content,
replyToId: dto.replyToId,
storageKey: dto.storageKey,
mimeType: dto.mimeType,
metadataJson: dto.metadataJson,
poll: dto.poll
})
);
}
@Post('messages/:messageId/vote')
@ApiOperation({ summary: 'Голос в опросе' })
async votePoll(
@Headers('authorization') authorization: string | undefined,
@Param('messageId') messageId: string,
@Body() dto: VotePollDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.VotePoll({ userId, messageId, optionIds: dto.optionIds }));
}
@Post('rooms/:roomId/mute')
@ApiOperation({ summary: 'Выключить/включить уведомления чата' })
async setMuted(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: SetRoomNotificationsMutedDto
) {
const userId = await this.auth(authorization);
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
}
}

View File

@@ -0,0 +1,97 @@
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
@ApiTags('Хранилище данных и документы')
@ApiBearerAuth()
@Controller('documents/users/:userId')
export class DocumentsController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post()
@ApiOperation({ summary: 'Создать документ', description: 'Создает документ пользователя с данными формы. Поля шифруются в SSO Core.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: CreateDocumentDto })
async create(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Body() dto: CreateDocumentDto
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(
this.core.documents.CreateDocument({
userId,
type: dto.type,
number: dto.number ?? '—',
issuedAt: dto.issuedAt,
expiresAt: dto.expiresAt,
metadataJson: dto.metadataJson
})
);
}
@Get()
@ApiOperation({ summary: 'Список документов', description: 'Возвращает все документы пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(
this.core.documents.ListDocuments({ userId }).pipe(
map((response) => {
const payload = response as { documents?: unknown[] };
return { documents: payload.documents ?? [] };
})
)
);
}
@Get(':documentId')
@ApiOperation({ summary: 'Получить документ', description: 'Возвращает один документ пользователя с расшифрованными полями.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
async get(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string
) {
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.GetDocument({ userId, documentId }));
}
@Patch(':documentId')
@ApiOperation({ summary: 'Обновить документ', description: 'Обновляет данные формы документа.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
@ApiBody({ type: UpdateDocumentDto })
async update(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string,
@Body() dto: UpdateDocumentDto
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.UpdateDocument({ userId, documentId, ...dto }));
}
@Delete(':documentId')
@ApiOperation({ summary: 'Удалить документ', description: 'Удаляет документ пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'documentId', description: 'ID документа' })
@ApiResponse({ status: 200, description: 'Документ удалён' })
async delete(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Param('documentId') documentId: string
) {
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(this.core.documents.DeleteDocument({ userId, documentId }));
}
}

View File

@@ -0,0 +1,130 @@
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);

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'api-gateway' };
}
}

View File

@@ -0,0 +1,215 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtService } from '@nestjs/jwt';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto';
import { buildContentDisposition } from '../media-content-disposition';
type StreamResponse = {
setHeader: (key: string, value: string) => void;
status: (code: number) => { json: (body: unknown) => void };
} & NodeJS.WritableStream;
@ApiTags('Медиа')
@Controller('media')
export class MediaController {
private s3Client: S3Client | null = null;
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private getS3Client() {
if (!this.s3Client) {
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
const useSsl = process.env.MINIO_USE_SSL === 'true';
this.s3Client = new S3Client({
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
region: process.env.MINIO_REGION ?? 'us-east-1',
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
},
forcePathStyle: true
});
}
return this.s3Client;
}
private async authUserId(authorization?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization);
}
@Post('avatars/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
@ApiBody({ type: AvatarUploadDto })
async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType }));
}
@Post('avatars/confirm')
@ApiBearerAuth()
@ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' })
@ApiBody({ type: ConfirmAvatarDto })
async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey }));
}
@Get('avatars/:userId/url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId }));
}
@Post('documents/:documentId/photo/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
@ApiBody({ type: DocumentPhotoUploadDto })
async createDocumentPhotoUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('documentId') documentId: string,
@Body() dto: DocumentPhotoUploadDto
) {
const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType }));
}
@Get('users/:userId/documents/photo-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
async getDocumentPhotoUrl(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Query('storageKey') storageKey: string
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey })
);
}
@Get('stream/:token')
@ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' })
@ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' })
@ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' })
async stream(
@Param('token') token: string,
@Headers('authorization') authorization: string | undefined,
@Query('download') download: string | undefined,
@Res({ passthrough: false }) response: StreamResponse
) {
let requesterId: string | undefined;
if (authorization) {
try {
requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
} catch {
requesterId = undefined;
}
}
const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId }));
const payload = resolved as { storageKey: string; contentType: string; fileName?: string };
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
const object = await this.getS3Client().send(
new GetObjectCommand({
Bucket: bucket,
Key: payload.storageKey
})
);
response.setHeader('Content-Type', payload.contentType);
response.setHeader('Cache-Control', 'private, no-store');
if (payload.fileName) {
const asAttachment = download === '1' || download === 'true';
response.setHeader(
'Content-Disposition',
buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName)
);
}
const body = object.Body;
if (!body) {
response.status(404).json({ message: 'Файл не найден' });
return;
}
const stream = body as NodeJS.ReadableStream;
stream.pipe(response);
}
@Post('families/:groupId/avatar/upload-url')
@ApiBearerAuth()
async createFamilyAvatarUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: AvatarUploadDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType })
);
}
@Post('families/:groupId/avatar/confirm')
@ApiBearerAuth()
async confirmFamilyAvatar(
@Headers('authorization') authorization: string | undefined,
@Param('groupId') groupId: string,
@Body() dto: ConfirmAvatarDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey })
);
}
@Get('families/:groupId/avatar/url')
@ApiBearerAuth()
async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId }));
}
@Post('chat/:roomId/media/upload-url')
@ApiBearerAuth()
async createChatMediaUploadUrl(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Body() dto: ChatMediaUploadDto
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.CreateChatMediaUploadUrl({
requesterId,
roomId,
contentType: dto.contentType,
fileName: dto.fileName
})
);
}
@Get('chat/:roomId/media/url')
@ApiBearerAuth()
async getChatMediaUrl(
@Headers('authorization') authorization: string | undefined,
@Param('roomId') roomId: string,
@Query('storageKey') storageKey: string,
@Query('fileName') fileName?: string
) {
const requesterId = await this.authUserId(authorization);
return firstValueFrom(
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
);
}
}

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiOperation, 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 { MarkNotificationReadDto } from '../dto/notifications.dto';
@ApiTags('Уведомления')
@ApiBearerAuth()
@Controller('notifications')
export class NotificationsController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get()
@ApiOperation({ summary: 'Список уведомлений' })
async list(
@Headers('authorization') authorization: string | undefined,
@Query('limit') limit?: string,
@Query('unreadOnly') unreadOnly?: string
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(
this.core.notifications.ListNotifications({
userId,
limit: limit ? Number(limit) : 30,
unreadOnly: unreadOnly === 'true'
}).pipe(
map((response) => {
const payload = response as { notifications?: unknown[] };
return { notifications: payload.notifications ?? [] };
})
)
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
async unreadCount(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
}
@Patch(':notificationId/read')
@ApiOperation({ summary: 'Удалить просмотренное уведомление' })
async markRead(
@Headers('authorization') authorization: string | undefined,
@Param('notificationId') notificationId: string,
@Body() _dto: MarkNotificationReadDto
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
}
@Delete(':notificationId')
@ApiOperation({ summary: 'Удалить уведомление' })
async deleteNotification(
@Headers('authorization') authorization: string | undefined,
@Param('notificationId') notificationId: string
) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
}
@Post('read-all')
@ApiOperation({ summary: 'Удалить все уведомления' })
async markAllRead(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
}
@Delete()
@ApiOperation({ summary: 'Удалить все уведомления' })
async deleteAll(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
}
}

View File

@@ -0,0 +1,38 @@
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
import { extractBearerToken } from '../auth-token';
@ApiTags('OAuth 2.0')
@Controller('oauth')
export class OAuthController {
constructor(private readonly core: CoreGrpcService) {}
@Get('authorize')
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
@ApiResponse({ status: 200, description: 'Authorization code создан' })
authorize(@Query() query: OAuthAuthorizeQueryDto) {
return this.core.oauth.Authorize(query);
}
@Post('token')
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
@ApiBody({ type: OAuthTokenDto })
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
token(@Body() dto: OAuthTokenDto) {
return this.core.oauth.Token(dto);
}
@Get('userinfo')
@ApiBearerAuth()
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
userInfo(@Headers('authorization') authorization?: string) {
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
}
}

View File

@@ -0,0 +1,26 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { SendOtpDto, VerifyOtpDto } from '../dto/identity.dto';
@ApiTags('OTP и 2FA')
@Controller('auth/otp')
export class OtpController {
constructor(private readonly core: CoreGrpcService) {}
@Post('send')
@ApiOperation({ summary: 'Отправить OTP код', description: 'Создает одноразовый код в AuthCode. Fallback-доставка пишет код в console.log.' })
@ApiBody({ type: SendOtpDto })
@ApiResponse({ status: 201, description: 'Код создан и отправлен' })
send(@Body() dto: SendOtpDto) {
return this.core.otp.SendOtp(dto);
}
@Post('verify')
@ApiOperation({ summary: 'Проверить OTP код', description: 'Проверяет одноразовый код, срок жизни и помечает его использованным.' })
@ApiBody({ type: VerifyOtpDto })
@ApiResponse({ status: 201, description: 'Код подтвержден' })
verify(@Body() dto: VerifyOtpDto) {
return this.core.otp.VerifyOtp(dto);
}
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto';
@ApiTags('Профиль и биометрия')
@ApiBearerAuth()
@Controller('profile/users/:userId')
export class ProfileController {
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
private async assertSelfAccess(authorization: string | undefined, userId: string) {
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
if (requesterId !== userId) {
throw new ForbiddenException('Можно изменять только свой профиль');
}
return requesterId;
}
@Get()
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
getProfile(@Param('userId') userId: string) {
return this.core.profile.GetProfile({ userId });
}
@Patch('avatar')
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: UpdateAvatarDto })
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
@ApiTags('Публичные настройки')
@Controller('settings')
export class PublicSettingsController {
constructor(private readonly core: CoreGrpcService) {}
@Get('public')
@ApiOperation({ summary: 'Публичные настройки', description: 'Возвращает несекретные настройки для интерфейса без авторизации.' })
listPublic() {
return this.core.settings.ListPublicSettings({}).pipe(
map((response) => {
const payload = response as { settings?: Array<{ key: string; value: string }> };
return { settings: payload.settings ?? [] };
})
);
}
}

View File

@@ -0,0 +1,103 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
@ApiTags('RBAC и OAuth')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/rbac')
export class RbacController {
constructor(private readonly core: CoreGrpcService) {}
@Get('roles')
@ApiOperation({ summary: 'Список ролей', description: 'Возвращает роли вместе с назначенными правами.' })
listRoles() {
return this.core.rbac.ListRoles({}).pipe(
map((response) => {
const payload = response as { roles?: Array<{ permissions?: unknown[] }> };
return {
roles: (payload.roles ?? []).map((role) => ({
...role,
permissions: role.permissions ?? []
}))
};
})
);
}
@Get('permissions')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
listPermissions() {
return this.core.rbac.ListPermissions({});
}
@Get('oauth-scopes')
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.ListOAuthScopes({});
}
@Get('oauth-clients')
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.ListOAuthClients({});
}
@Post('roles')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
@ApiBody({ type: CreateRoleDto })
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
}
@Post('users/:userId/roles')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: AssignUserRoleDto })
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.AssignUserRole({ actorUserId: admin.id, userId, roleSlug: dto.roleSlug });
}
@Delete('users/:userId/roles/:roleSlug')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
}
@Post('oauth-clients')
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
@ApiBody({ type: CreateOAuthClientDto })
createOAuthClient(@Body() dto: CreateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.CreateOAuthClient({ actorUserId: admin.id, ...dto });
}
@Patch('oauth-clients/:clientId')
@ApiOperation({ summary: 'Обновить OAuth-приложение', description: 'Изменяет redirect URI, scopes и статус приложения.' })
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
@ApiBody({ type: UpdateOAuthClientDto })
updateOAuthClient(@Param('clientId') clientId: string, @Body() dto: UpdateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
}
@Post('oauth-clients/:clientId/rotate-secret')
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
rotateSecret(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.RotateOAuthSecret({ actorUserId: admin.id, clientId });
}
}

View File

@@ -0,0 +1,111 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
@ApiTags('Безопасность')
@ApiBearerAuth()
@Controller('security')
export class SecurityController {
constructor(private readonly core: CoreGrpcService) {}
@Get('users/:userId/devices')
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список устройств получен' })
listDevices(@Param('userId') userId: string) {
return this.core.security.ListActiveDevices({ userId });
}
@Get('users/:userId/sessions')
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
listSessions(@Param('userId') userId: string) {
return this.core.security.ListActiveSessions({ userId });
}
@Get('users/:userId/sign-in-history')
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'История входов получена' })
listHistory(@Param('userId') userId: string) {
return this.core.security.ListSignInHistory({ userId });
}
@Post('users/:userId/pin/setup')
@ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: PinDto })
@ApiResponse({ status: 201, description: 'PIN-код настроен' })
setupPin(@Param('userId') userId: string, @Body() dto: PinDto) {
return this.core.security.SetupPin({ userId, pin: dto.pin });
}
@Post('users/:userId/pin/update')
@ApiOperation({ summary: 'Обновить PIN-код', description: 'Меняет PIN-код и переводит активные сессии пользователя в LOCKED до повторной проверки.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: PinDto })
@ApiResponse({ status: 201, description: 'PIN-код обновлен' })
updatePin(@Param('userId') userId: string, @Body() dto: PinDto) {
return this.core.security.UpdatePin({ userId, pin: dto.pin });
}
@Post('users/:userId/pin/delete-request')
@ApiOperation({ summary: 'Запросить удаление PIN-кода', description: 'Запускает период ожидания перед отключением PIN-кода согласно SystemSetting.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: OptionalPinDto })
requestPinDeletion(@Param('userId') userId: string, @Body() dto: OptionalPinDto) {
return this.core.security.RequestPinDeletion({ userId, pin: dto.pin ?? '' });
}
@Post('users/:userId/pin/delete-cancel')
@ApiOperation({ summary: 'Отменить удаление PIN-кода', description: 'Отменяет запланированное удаление PIN-кода.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
cancelPinDeletion(@Param('userId') userId: string) {
return this.core.security.CancelPinDeletion({ userId });
}
@Post('pin/verify')
@ApiOperation({ summary: 'Проверить PIN-код', description: 'Проверяет PIN-код и возвращает access token с pinVerified=true для указанной сессии.' })
@ApiBody({ type: VerifySecurityPinDto })
@ApiResponse({ status: 201, description: 'PIN-код проверен' })
verifyPin(@Body() dto: VerifySecurityPinDto) {
return this.core.security.VerifyPinCode(dto);
}
@Post('users/:userId/sessions/:sessionId/lock')
@ApiOperation({ summary: 'Заблокировать сессию', description: 'Переводит активную сессию в LOCKED и сбрасывает pinVerified=false.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
@ApiResponse({ status: 201, description: 'Сессия заблокирована' })
lockSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
return this.core.security.LockSession({ userId, sessionId });
}
@Post('users/:userId/sessions/:sessionId/revoke')
@ApiOperation({ summary: 'Выйти с устройства', description: 'Отзывает конкретную сессию пользователя и завершает доступ с выбранного устройства.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
@ApiResponse({ status: 201, description: 'Сессия отозвана' })
revokeSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
return this.core.security.RevokeSession({ userId, sessionId });
}
@Post('users/:userId/devices/:deviceId/revoke')
@ApiOperation({ summary: 'Завершить сессии устройства', description: 'Отзывает все активные сессии выбранного устройства пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'deviceId', description: 'ID устройства' })
@ApiResponse({ status: 201, description: 'Сессии устройства завершены' })
revokeDevice(@Param('userId') userId: string, @Param('deviceId') deviceId: string) {
return this.core.security.RevokeDevice({ userId, deviceId });
}
@Post('users/:userId/revoke-all-sessions')
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
revokeAll(@Param('userId') userId: string) {
return this.core.security.RevokeAllSessions({ userId });
}
}

View File

@@ -0,0 +1,125 @@
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
const settingsWritePipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false
});
@ApiTags('Глобальные настройки')
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller('admin/settings')
export class SettingsController {
constructor(private readonly core: CoreGrpcService) {}
@Get()
@ApiOperation({ summary: 'Список системных настроек', description: 'Возвращает динамические настройки, включая PIN timeout и OAuth providers.' })
@ApiResponse({ status: 200, description: 'Список настроек получен' })
list(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.ListSettings({}).pipe(
map((response) => {
const payload = response as { settings?: unknown[] };
return { settings: payload.settings ?? [] };
})
);
}
@Get('key/:key')
@ApiOperation({ summary: 'Получить настройку', description: 'Возвращает SystemSetting по ключу.' })
@ApiParam({ name: 'key', description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
@ApiResponse({ status: 200, description: 'Настройка получена' })
get(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.GetSetting({ key });
}
@Put()
@UsePipes(settingsWritePipe)
@ApiOperation({ summary: 'Создать или обновить настройку', description: 'Меняет значение SystemSetting без изменения кода frontend.' })
@ApiBody({ type: UpsertSettingDto })
@ApiResponse({ status: 200, description: 'Настройка создана или обновлена' })
upsert(@Body() dto: UpsertSettingDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.UpsertSetting({
key: dto.key,
value: dto.value,
description: dto.description,
isSecret: dto.isSecret
});
}
@Delete('key/:key')
@ApiOperation({ summary: 'Удалить настройку', description: 'Удаляет SystemSetting по ключу.' })
@ApiParam({ name: 'key', description: 'Ключ настройки' })
@ApiResponse({ status: 200, description: 'Настройка удалена' })
delete(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.DeleteSetting({ key });
}
@Get('oauth/providers')
@ApiOperation({ summary: 'Список OAuth провайдеров', description: 'Возвращает глобальные настройки Google/Yandex и других social login providers.' })
@ApiResponse({ status: 200, description: 'Список провайдеров получен' })
listProviders(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.ListSocialProviders({});
}
@Put('oauth/providers')
@UsePipes(settingsWritePipe)
@ApiOperation({ summary: 'Создать или обновить OAuth провайдера', description: 'Управляет clientId/clientSecret и глобальным включением Google/Yandex входа.' })
@ApiBody({ type: UpsertSocialProviderDto })
@ApiResponse({ status: 200, description: 'Провайдер создан или обновлен' })
upsertProvider(@Body() dto: UpsertSocialProviderDto, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.UpsertSocialProvider({
providerName: dto.providerName,
clientId: dto.clientId,
clientSecret: dto.clientSecret,
isEnabled: dto.isEnabled
});
}
@Delete('oauth/providers/:providerName')
@ApiOperation({ summary: 'Удалить OAuth провайдера', description: 'Удаляет глобальную настройку social login provider.' })
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
@ApiResponse({ status: 200, description: 'Провайдер удален' })
deleteProvider(@Param('providerName') providerName: string, @CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageSettings');
return this.core.settings.DeleteSocialProvider({ providerName });
}
@Get('linked-accounts/users/:userId')
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список внешних аккаунтов получен' })
listLinkedAccounts(@Param('userId') userId: string) {
return this.core.security.ListLinkedAccounts({ userId });
}
@Put('linked-accounts/users/:userId')
@ApiOperation({ summary: 'Подключить внешний аккаунт', description: 'Создает или обновляет LinkedAccount для social OAuth логина пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: ConnectLinkedAccountDto })
@ApiResponse({ status: 200, description: 'Внешний аккаунт подключен' })
connectLinkedAccount(@Param('userId') userId: string, @Body() dto: ConnectLinkedAccountDto) {
return this.core.security.ConnectLinkedAccount({ userId, ...dto });
}
@Delete('linked-accounts/users/:userId/:providerName')
@ApiOperation({ summary: 'Отключить внешний аккаунт', description: 'Удаляет связь пользователя с Google/Yandex или другим social provider.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
@ApiResponse({ status: 200, description: 'Внешний аккаунт отключен' })
disconnectLinkedAccount(@Param('userId') userId: string, @Param('providerName') providerName: string) {
return this.core.security.DisconnectLinkedAccount({ userId, providerName });
}
}

View File

@@ -0,0 +1,44 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { Observable } from 'rxjs';
type GrpcMethod<TRequest = unknown, TResponse = unknown> = (request: TRequest) => Observable<TResponse>;
@Injectable()
export class CoreGrpcService implements OnModuleInit {
auth!: Record<string, GrpcMethod>;
admin!: Record<string, GrpcMethod>;
rbac!: Record<string, GrpcMethod>;
security!: Record<string, GrpcMethod>;
settings!: Record<string, GrpcMethod>;
profile!: Record<string, GrpcMethod>;
documents!: Record<string, GrpcMethod>;
addresses!: Record<string, GrpcMethod>;
oauth!: Record<string, GrpcMethod>;
otp!: Record<string, GrpcMethod>;
advancedAuth!: Record<string, GrpcMethod>;
notifications!: Record<string, GrpcMethod>;
chat!: Record<string, GrpcMethod>;
family!: Record<string, GrpcMethod>;
media!: Record<string, GrpcMethod>;
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
onModuleInit() {
this.auth = this.client.getService<Record<string, GrpcMethod>>('AuthService');
this.admin = this.client.getService<Record<string, GrpcMethod>>('AdminService');
this.rbac = this.client.getService<Record<string, GrpcMethod>>('RbacService');
this.security = this.client.getService<Record<string, GrpcMethod>>('SecurityService');
this.settings = this.client.getService<Record<string, GrpcMethod>>('SettingsService');
this.profile = this.client.getService<Record<string, GrpcMethod>>('ProfileService');
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
}
}

View File

@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AdminRequestUser } from '../guards/admin.guard';
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminRequestUser => {
const request = context.switchToHttp().getRequest<{ adminUser: AdminRequestUser }>();
return request.adminUser;
});

View File

@@ -0,0 +1,52 @@
import { ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from './core-grpc.service';
import { assertSessionUnlocked, verifyAccessToken } from './session-auth';
interface RequesterProfile {
id: string;
isSuperAdmin: boolean;
canViewUserDocuments?: boolean;
}
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
const payload = await verifyAccessToken(jwt, authorization);
await assertSessionUnlocked(core, payload);
const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string };
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
}
export async function assertDocumentsReadAccess(
jwt: JwtService,
core: CoreGrpcService,
authorization: string | undefined,
targetUserId: string
) {
const requester = await getRequesterProfile(jwt, core, authorization);
if (requester.id === targetUserId) {
return requester.id;
}
if (!requester.canViewUserDocuments && !requester.isSuperAdmin) {
throw new ForbiddenException('Нет доступа к документам пользователя');
}
return requester.id;
}
export async function assertDocumentsWriteAccess(
jwt: JwtService,
core: CoreGrpcService,
authorization: string | undefined,
targetUserId: string
) {
const requester = await getRequesterProfile(jwt, core, authorization);
if (requester.id !== targetUserId) {
throw new ForbiddenException('Нельзя изменять документы другого пользователя');
}
return requester.id;
}
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) {
const requester = await getRequesterProfile(jwt, core, authorization);
return requester.id;
}

View File

@@ -0,0 +1,53 @@
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpsertAddressDto {
@ApiPropertyOptional({ description: 'ID адреса для обновления' })
@IsOptional()
@IsUUID('4', { message: 'ID адреса должен быть UUID' })
addressId?: string;
@ApiProperty({ description: 'Тип адреса', enum: ['HOME', 'WORK', 'OTHER'] })
@IsIn(['HOME', 'WORK', 'OTHER'], { message: 'Некорректный тип адреса' })
label!: 'HOME' | 'WORK' | 'OTHER';
@ApiProperty({ description: 'Город' })
@IsString({ message: 'Город должен быть строкой' })
@MinLength(1, { message: 'Укажите город' })
city!: string;
@ApiProperty({ description: 'Улица' })
@IsString({ message: 'Улица должна быть строкой' })
@MinLength(1, { message: 'Укажите улицу' })
street!: string;
@ApiProperty({ description: 'Дом' })
@IsString({ message: 'Дом должен быть строкой' })
@MinLength(1, { message: 'Укажите дом' })
house!: string;
@ApiPropertyOptional({ description: 'Квартира или офис' })
@IsOptional()
@IsString({ message: 'Квартира должна быть строкой' })
apartment?: string;
@ApiPropertyOptional({ description: 'Комментарий' })
@IsOptional()
@IsString({ message: 'Комментарий должен быть строкой' })
comment?: string;
@ApiPropertyOptional({ description: 'Широта' })
@IsOptional()
@IsNumber({}, { message: 'Широта должна быть числом' })
latitude?: number;
@ApiPropertyOptional({ description: 'Долгота' })
@IsOptional()
@IsNumber({}, { message: 'Долгота должна быть числом' })
longitude?: number;
@ApiPropertyOptional({ description: 'Полный адрес одной строкой' })
@IsOptional()
@IsString({ message: 'Полный адрес должен быть строкой' })
fullAddress?: string;
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, Matches, MinLength } from 'class-validator';
export enum GatewayUserStatus {
ACTIVE = 'ACTIVE',
SUSPENDED = 'SUSPENDED',
DELETED = 'DELETED'
}
export class ListUsersQueryDto {
@ApiPropertyOptional({ description: 'Поиск по почте, телефону, имени или логину' })
@IsOptional()
@IsString({ message: 'Поисковая строка должна быть текстом' })
search?: string;
}
export class UpdateUserDto {
@ApiPropertyOptional({ description: 'Отображаемое имя' })
@IsOptional()
@IsString({ message: 'Имя должно быть строкой' })
displayName?: string;
@ApiPropertyOptional({ description: 'Основная почта' })
@IsOptional()
@IsEmail({}, { message: 'Укажите корректную почту' })
email?: string;
@ApiPropertyOptional({ description: 'Основной телефон' })
@IsOptional()
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' })
phone?: string;
@ApiPropertyOptional({ description: 'Резервная почта' })
@IsOptional()
@IsEmail({}, { message: 'Укажите корректную резервную почту' })
backupEmail?: string;
@ApiPropertyOptional({ description: 'Резервный телефон' })
@IsOptional()
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' })
backupPhone?: string;
@ApiPropertyOptional({ description: 'Статус пользователя', enum: GatewayUserStatus })
@IsOptional()
@IsEnum(GatewayUserStatus, { message: 'Укажите корректный статус пользователя' })
status?: GatewayUserStatus;
}
export class ResetPasswordDto {
@ApiProperty({ description: 'Новый пароль пользователя', minLength: 8 })
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
newPassword!: string;
}
export class SetSuperAdminDto {
@ApiProperty({ description: 'Выдать или снять права супер-администратора' })
@IsBoolean({ message: 'isSuperAdmin должно быть boolean' })
isSuperAdmin!: boolean;
}

View File

@@ -0,0 +1,189 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, Matches, MinLength } from 'class-validator';
const EMAIL_OR_PHONE_PATTERN = /^([^\s@]+@[^\s@]+\.[^\s@]+|\+?[1-9]\d{9,14})$/;
export class RegisterDto {
@ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' })
@IsOptional()
@IsEmail({}, { message: 'Укажите корректную почту' })
email?: string;
@ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' })
@IsOptional()
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный номер телефона' })
phone?: string;
@ApiPropertyOptional({ description: 'Пароль пользователя. Не требуется для passwordless-регистрации.', minLength: 8 })
@IsOptional()
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
password?: string;
@ApiProperty({ description: 'Имя, которое отображается в профиле', example: 'Иван Петров' })
@IsString({ message: 'Имя должно быть строкой' })
@IsNotEmpty({ message: 'Укажите имя профиля' })
displayName!: string;
@ApiPropertyOptional({ description: 'Публичный логин пользователя', example: 'ivan' })
@IsOptional()
@IsString({ message: 'Логин должен быть строкой' })
username?: string;
}
export class LoginDto {
@ApiProperty({ description: 'Почта, телефон или логин', example: 'user@example.com' })
@IsString({ message: 'Логин должен быть строкой' })
@IsNotEmpty({ message: 'Укажите логин' })
login!: string;
@ApiProperty({ description: 'Пароль пользователя' })
@IsString({ message: 'Пароль должен быть строкой' })
@IsNotEmpty({ message: 'Укажите пароль' })
password!: string;
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
@IsNotEmpty({ message: 'Передайте отпечаток устройства' })
fingerprint!: string;
@ApiPropertyOptional({ description: 'Название устройства', example: 'Chrome на Windows' })
@IsOptional()
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName?: string;
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
@IsOptional()
@IsString({ message: 'Тип устройства должен быть строкой' })
deviceType?: string;
}
export class IdentifyDto {
@ApiProperty({ description: 'Почта или телефон пользователя', example: 'user@example.com' })
@IsString({ message: 'Логин должен быть строкой' })
@IsNotEmpty({ message: 'Укажите почту или телефон' })
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
login!: string;
}
export class PasswordlessOtpDto {
@ApiProperty({ description: 'Почта или телефон для входа', example: 'user@example.com' })
@IsString({ message: 'Получатель должен быть строкой' })
@IsNotEmpty({ message: 'Укажите почту или телефон' })
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
recipient!: string;
@ApiPropertyOptional({ description: 'Альтернативный канал доставки кода', enum: ['primary', 'email', 'phone', 'backupEmail', 'backupPhone'] })
@IsOptional()
@IsString({ message: 'Канал должен быть строкой' })
channel?: string;
}
export class PasswordlessVerifyDto {
@ApiProperty({ description: 'Почта или телефон, на который отправлен код', example: 'user@example.com' })
@IsString({ message: 'Получатель должен быть строкой' })
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
recipient!: string;
@ApiProperty({ description: 'OTP-код из 6 цифр', example: '123456' })
@IsString({ message: 'Код должен быть строкой' })
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
code!: 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 PasswordLoginDto {
@ApiPropertyOptional({ description: 'Почта или телефон пользователя для identifier-first входа' })
@IsOptional()
@IsString({ message: 'Логин должен быть строкой' })
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
login?: string;
@ApiPropertyOptional({ description: 'Временный auth_token после успешной OTP-проверки, если используется legacy challenge flow' })
@IsOptional()
@IsString({ message: 'Временный токен должен быть строкой' })
tempAuthToken?: string;
@ApiProperty({ description: 'Пароль пользователя' })
@IsString({ message: 'Пароль должен быть строкой' })
@IsNotEmpty({ message: 'Укажите пароль' })
password!: 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 VerifyPinDto {
@ApiProperty({ description: 'ID временно заблокированной сессии' })
@IsString({ message: 'ID сессии должен быть строкой' })
sessionId!: string;
@ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' })
@IsString({ message: 'PIN-код должен быть строкой' })
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
pin!: string;
}
export class RefreshSessionDto {
@ApiProperty({ description: 'Refresh token текущей сессии' })
@IsString({ message: 'Refresh token должен быть строкой' })
@IsNotEmpty({ message: 'Передайте refresh token' })
refreshToken!: string;
@ApiPropertyOptional({ description: 'ID сессии для ускоренной проверки' })
@IsOptional()
@IsString({ message: 'ID сессии должен быть строкой' })
sessionId?: string;
}
export class LdapLoginDto {
@ApiProperty({ description: 'Логин LDAP (sAMAccountName, uid, mail и т.д.)', example: 'ivan.petrov' })
@IsString({ message: 'Логин должен быть строкой' })
@IsNotEmpty({ message: 'Укажите LDAP-логин' })
username!: string;
@ApiProperty({ description: 'Пароль LDAP' })
@IsString({ message: 'Пароль должен быть строкой' })
@IsNotEmpty({ message: 'Укажите пароль' })
password!: string;
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
fingerprint!: string;
@ApiPropertyOptional({ description: 'Название устройства' })
@IsOptional()
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName?: string;
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
@IsOptional()
@IsString({ message: 'Тип устройства должен быть строкой' })
deviceType?: string;
}

View File

@@ -0,0 +1,68 @@
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateChatRoomDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberUserIds?: string[];
}
export class UpdateChatRoomDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsBoolean()
notificationsMuted?: boolean;
}
export class SendChatMessageDto {
@IsString()
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
type!: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
replyToId?: string;
@IsOptional()
@IsString()
storageKey?: string;
@IsOptional()
@IsString()
mimeType?: string;
@IsOptional()
@IsString()
metadataJson?: string;
@IsOptional()
poll?: {
question: string;
options: string[];
allowsMultiple?: boolean;
isAnonymous?: boolean;
};
}
export class VotePollDto {
@IsArray()
@IsString({ each: true })
optionIds!: string[];
}
export class SetRoomNotificationsMutedDto {
@IsBoolean()
muted!: boolean;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsJSON, IsOptional, IsString } from 'class-validator';
export enum DocumentTypeDto {
PASSPORT_RF = 'PASSPORT_RF',
FOREIGN_PASSPORT = 'FOREIGN_PASSPORT',
BIRTH_CERTIFICATE = 'BIRTH_CERTIFICATE',
DRIVER_LICENSE = 'DRIVER_LICENSE',
VEHICLE_REGISTRATION = 'VEHICLE_REGISTRATION',
OMS = 'OMS',
DMS = 'DMS',
INN = 'INN',
SNILS = 'SNILS'
}
export class CreateDocumentDto {
@ApiProperty({ description: 'Тип документа', enum: DocumentTypeDto, example: DocumentTypeDto.PASSPORT_RF })
@IsEnum(DocumentTypeDto, { message: 'Некорректный тип документа' })
type!: DocumentTypeDto;
@ApiPropertyOptional({ description: 'Номер документа. Перед сохранением будет зашифрован.', example: '4512 123456' })
@IsOptional()
@IsString({ message: 'Номер документа должен быть строкой' })
number?: string;
@ApiPropertyOptional({ description: 'Дата выдачи документа в ISO формате', example: '2020-01-10T00:00:00.000Z' })
@IsOptional()
@IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' })
issuedAt?: string;
@ApiPropertyOptional({ description: 'Дата окончания действия документа в ISO формате', example: '2030-01-10T00:00:00.000Z' })
@IsOptional()
@IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' })
expiresAt?: string;
@ApiPropertyOptional({ description: 'Данные формы документа в JSON. Перед сохранением будут зашифрованы.' })
@IsOptional()
@IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' })
metadataJson?: string;
}
export class UpdateDocumentDto {
@ApiPropertyOptional({ description: 'Номер документа' })
@IsOptional()
@IsString({ message: 'Номер документа должен быть строкой' })
number?: string;
@ApiPropertyOptional({ description: 'Дата выдачи в ISO формате' })
@IsOptional()
@IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' })
issuedAt?: string;
@ApiPropertyOptional({ description: 'Дата окончания в ISO формате' })
@IsOptional()
@IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' })
expiresAt?: string;
@ApiPropertyOptional({ description: 'Данные формы документа в JSON' })
@IsOptional()
@IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' })
metadataJson?: string;
}

View File

@@ -0,0 +1,140 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class OAuthAuthorizeQueryDto {
@ApiProperty({ description: 'ID пользователя, который подтверждает OAuth доступ' })
@IsString({ message: 'ID пользователя должен быть строкой' })
userId!: string;
@ApiProperty({ description: 'OAuth client_id приложения' })
@IsString({ message: 'client_id должен быть строкой' })
clientId!: string;
@ApiProperty({ description: 'Разрешенный redirect_uri приложения' })
@IsString({ message: 'redirect_uri должен быть строкой' })
redirectUri!: string;
@ApiProperty({ description: 'Запрошенные scopes через пробел', example: 'openid profile email' })
@IsString({ message: 'scope должен быть строкой' })
scope!: string;
@ApiPropertyOptional({ description: 'OAuth state для защиты клиента' })
@IsOptional()
@IsString({ message: 'state должен быть строкой' })
state?: string;
}
export class OAuthTokenDto {
@ApiProperty({ description: 'Тип grant', enum: ['authorization_code', 'refresh_token'] })
@IsIn(['authorization_code', 'refresh_token'], { message: 'grant_type должен быть authorization_code или refresh_token' })
grantType!: string;
@ApiPropertyOptional({ description: 'Authorization code' })
@IsOptional()
@IsString({ message: 'code должен быть строкой' })
code?: string;
@ApiPropertyOptional({ description: 'Refresh token' })
@IsOptional()
@IsString({ message: 'refresh_token должен быть строкой' })
refreshToken?: string;
@ApiProperty({ description: 'OAuth client_id' })
@IsString({ message: 'client_id должен быть строкой' })
clientId!: string;
@ApiPropertyOptional({ description: 'OAuth client_secret' })
@IsOptional()
@IsString({ message: 'client_secret должен быть строкой' })
clientSecret?: string;
@ApiPropertyOptional({ description: 'redirect_uri для authorization_code grant' })
@IsOptional()
@IsString({ message: 'redirect_uri должен быть строкой' })
redirectUri?: string;
}
export class SendOtpDto {
@ApiProperty({ description: 'Почта или телефон получателя' })
@IsString({ message: 'Получатель должен быть строкой' })
target!: string;
@ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] })
@IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' })
channel!: string;
@ApiProperty({ description: 'Назначение кода', example: 'login' })
@IsString({ message: 'Назначение должно быть строкой' })
purpose!: string;
@ApiPropertyOptional({ description: 'ID пользователя, если код привязан к аккаунту' })
@IsOptional()
@IsString({ message: 'ID пользователя должен быть строкой' })
userId?: string;
}
export class VerifyOtpDto {
@ApiProperty({ description: 'Почта или телефон получателя' })
@IsString({ message: 'Получатель должен быть строкой' })
target!: string;
@ApiProperty({ description: 'Код подтверждения' })
@IsString({ message: 'Код должен быть строкой' })
code!: string;
@ApiProperty({ description: 'Назначение кода', example: 'login' })
@IsString({ message: 'Назначение должно быть строкой' })
purpose!: string;
}
export class WebAuthnDto {
@ApiProperty({ description: 'ID пользователя' })
@IsString({ message: 'ID пользователя должен быть строкой' })
userId!: string;
}
export class QrSessionDto {
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName!: string;
}
export class CreateFamilyGroupDto {
@ApiProperty({ description: 'ID владельца семьи' })
@IsString({ message: 'ID владельца должен быть строкой' })
ownerId!: string;
@ApiProperty({ description: 'Название семейной группы', example: 'Семья Мамедовых' })
@IsString({ message: 'Название семьи должно быть строкой' })
@IsNotEmpty({ message: 'Укажите название семьи' })
name!: string;
}
export class UpdateFamilyGroupDto {
@ApiProperty({ description: 'Новое название семейной группы' })
@IsString({ message: 'Название семьи должно быть строкой' })
name!: string;
}
export class AddFamilyMemberDto {
@ApiProperty({ description: 'ID пользователя, которого нужно добавить' })
@IsString({ message: 'ID пользователя должен быть строкой' })
userId!: string;
@ApiProperty({ description: 'Роль участника семьи', example: 'member' })
@IsString({ message: 'Роль должна быть строкой' })
role!: string;
}
export class SendFamilyInviteDto {
@ApiProperty({ description: 'Email, телефон или логин приглашаемого' })
@IsString({ message: 'Укажите контакт приглашаемого' })
@IsNotEmpty({ message: 'Укажите контакт приглашаемого' })
target!: string;
}
export class RespondFamilyInviteDto {
@ApiProperty({ description: 'Принять приглашение' })
@IsBoolean({ message: 'Укажите accept: true или false' })
accept!: boolean;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
export class AvatarUploadDto {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' })
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
contentType!: string;
}
export class ConfirmAvatarDto {
@ApiProperty({ description: 'Ключ объекта в MinIO после загрузки', example: 'avatars/uuid/photo.jpg' })
@IsString({ message: 'Ключ хранилища должен быть строкой' })
storageKey!: string;
}
export class DocumentPhotoUploadDto {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' })
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
contentType!: string;
}
export class ChatMediaUploadDto {
@ApiProperty({ description: 'MIME-тип файла', example: 'audio/webm' })
@IsString({ message: 'Укажите MIME-тип файла' })
contentType!: string;
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'voice.webm' })
@IsOptional()
@IsString({ message: 'Имя файла должно быть строкой' })
fileName?: string;
}
export class UpdateAvatarStorageDto {
@ApiProperty({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' })
@IsString({ message: 'Ключ хранилища должен быть строкой' })
storageKey!: string;
}

View File

@@ -0,0 +1 @@
export class MarkNotificationReadDto {}

View File

@@ -0,0 +1,79 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Matches, Max, Min, MinLength } from 'class-validator';
export class UpdateAvatarDto {
@ApiPropertyOptional({ description: 'Публичная ссылка на аватар (legacy)', example: 'https://cdn.lendry.ru/avatars/user.png' })
@IsOptional()
@IsUrl({}, { message: 'Укажите корректную ссылку на аватар' })
avatarUrl?: string;
@ApiPropertyOptional({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' })
@IsOptional()
@IsString({ message: 'Ключ хранилища должен быть строкой' })
avatarStorageKey?: string;
}
export class UpdateProfileDto {
@ApiPropertyOptional({ description: 'Имя пользователя', example: 'Дмитрий' })
@IsOptional()
@IsString({ message: 'Имя должно быть строкой' })
firstName?: string;
@ApiPropertyOptional({ description: 'Фамилия пользователя', example: 'Мамедов' })
@IsOptional()
@IsString({ message: 'Фамилия должна быть строкой' })
lastName?: string;
@ApiPropertyOptional({ description: 'Краткое описание профиля', example: 'Разработчик и владелец аккаунта' })
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
bio?: string;
@ApiPropertyOptional({ description: 'Возраст пользователя', minimum: 0, maximum: 130, example: 24 })
@IsOptional()
@IsInt({ message: 'Возраст должен быть целым числом' })
@Min(0, { message: 'Возраст не может быть отрицательным' })
@Max(130, { message: 'Возраст указан некорректно' })
age?: number;
@ApiPropertyOptional({ description: 'Пол пользователя', example: 'male' })
@IsOptional()
@IsString({ message: 'Пол должен быть строкой' })
gender?: string;
}
export class UpdateContactsDto {
@ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' })
@IsOptional()
@IsEmail({}, { message: 'Укажите корректную почту' })
email?: string;
@ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' })
@IsOptional()
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' })
phone?: string;
@ApiPropertyOptional({ description: 'Резервная почта пользователя', example: 'backup@example.com' })
@IsOptional()
@IsEmail({}, { message: 'Укажите корректную резервную почту' })
backupEmail?: string;
@ApiPropertyOptional({ description: 'Резервный телефон пользователя', example: '+79991112233' })
@IsOptional()
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' })
backupPhone?: string;
}
export class SetPasswordDto {
@ApiProperty({ description: 'Новый пароль для входа', minLength: 8 })
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
password!: string;
}
export class UserIdParamDto {
@ApiProperty({ description: 'ID пользователя' })
@IsString({ message: 'ID пользователя должен быть строкой' })
@IsNotEmpty({ message: 'Передайте ID пользователя' })
userId!: string;
}

View File

@@ -0,0 +1,79 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
export enum GatewayOAuthClientType {
CONFIDENTIAL = 'CONFIDENTIAL',
PUBLIC = 'PUBLIC'
}
export class CreateRoleDto {
@ApiProperty({ description: 'Уникальный slug роли', example: 'support' })
@IsString({ message: 'Slug роли должен быть строкой' })
slug!: string;
@ApiProperty({ description: 'Название роли', example: 'Поддержка' })
@IsString({ message: 'Название роли должно быть строкой' })
name!: string;
@ApiPropertyOptional({ description: 'Описание роли' })
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
description?: string;
@ApiPropertyOptional({ description: 'Список slug прав', type: [String] })
@IsOptional()
@IsArray({ message: 'Права должны быть массивом' })
@IsString({ each: true, message: 'Каждое право должно быть строкой' })
permissionSlugs?: string[];
}
export class AssignUserRoleDto {
@ApiProperty({ description: 'Slug роли', example: 'admin' })
@IsString({ message: 'Slug роли должен быть строкой' })
roleSlug!: string;
}
export class CreateOAuthClientDto {
@ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' })
@IsString({ message: 'Название должно быть строкой' })
name!: string;
@ApiProperty({ description: 'Redirect URI', type: [String], example: ['https://app.example.com/oauth/callback'] })
@IsArray({ message: 'Redirect URI должны быть массивом' })
@IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' })
redirectUris!: string[];
@ApiProperty({ description: 'Scopes', type: [String], example: ['openid', 'profile', 'email'] })
@IsArray({ message: 'Scopes должны быть массивом' })
@IsString({ each: true, message: 'Каждый scope должен быть строкой' })
scopes!: string[];
@ApiPropertyOptional({ description: 'Тип клиента', enum: GatewayOAuthClientType })
@IsOptional()
@IsEnum(GatewayOAuthClientType, { message: 'Укажите корректный тип клиента' })
type?: GatewayOAuthClientType;
}
export class UpdateOAuthClientDto {
@ApiPropertyOptional({ description: 'Название приложения' })
@IsOptional()
@IsString({ message: 'Название должно быть строкой' })
name?: string;
@ApiPropertyOptional({ description: 'Redirect URI', type: [String] })
@IsOptional()
@IsArray({ message: 'Redirect URI должны быть массивом' })
@IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' })
redirectUris?: string[];
@ApiPropertyOptional({ description: 'Scopes', type: [String] })
@IsOptional()
@IsArray({ message: 'Scopes должны быть массивом' })
@IsString({ each: true, message: 'Каждый scope должен быть строкой' })
scopes?: string[];
@ApiPropertyOptional({ description: 'Активность приложения' })
@IsOptional()
@IsBoolean({ message: 'isActive должно быть boolean' })
isActive?: boolean;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Length, Matches } from 'class-validator';
export class PinDto {
@ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' })
@IsString({ message: 'PIN-код должен быть строкой' })
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
pin!: string;
}
export class OptionalPinDto {
@ApiPropertyOptional({ description: 'PIN-код для подтверждения действия' })
@IsOptional()
@IsString({ message: 'PIN-код должен быть строкой' })
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
pin?: string;
}
export class VerifySecurityPinDto extends PinDto {
@ApiProperty({ description: 'ID сессии, которую нужно разблокировать PIN-кодом' })
@IsString({ message: 'ID сессии должен быть строкой' })
sessionId!: string;
}

View File

@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpsertSettingDto {
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
@IsString({ message: 'Ключ настройки должен быть строкой' })
@IsNotEmpty({ message: 'Укажите ключ настройки' })
key!: string;
@ApiProperty({ description: 'Значение настройки', example: '15' })
@IsString({ message: 'Значение настройки должно быть строкой' })
value!: string;
@ApiPropertyOptional({ description: 'Описание настройки для администраторов' })
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
description?: string;
@ApiPropertyOptional({ description: 'Скрывать ли значение в интерфейсе' })
@IsOptional()
@IsBoolean({ message: 'Признак секрета должен быть логическим значением' })
isSecret?: boolean;
}
export class UpsertSocialProviderDto {
@ApiProperty({ description: 'Название OAuth провайдера', example: 'google' })
@IsString({ message: 'Название провайдера должно быть строкой' })
@IsNotEmpty({ message: 'Укажите провайдера' })
providerName!: string;
@ApiProperty({ description: 'OAuth Client ID провайдера' })
@IsString({ message: 'Client ID должен быть строкой' })
@IsNotEmpty({ message: 'Укажите Client ID' })
clientId!: string;
@ApiPropertyOptional({ description: 'OAuth Client Secret провайдера' })
@IsOptional()
@IsString({ message: 'Client Secret должен быть строкой' })
clientSecret?: string;
@ApiProperty({ description: 'Включен ли провайдер для входа' })
@IsBoolean({ message: 'Статус провайдера должен быть логическим значением' })
isEnabled!: boolean;
}
export class ConnectLinkedAccountDto {
@ApiProperty({ description: 'Название провайдера', example: 'google' })
@IsString({ message: 'Название провайдера должно быть строкой' })
providerName!: string;
@ApiProperty({ description: 'ID пользователя у внешнего провайдера', example: 'google-user-123' })
@IsString({ message: 'ID провайдера должен быть строкой' })
providerId!: string;
@ApiPropertyOptional({ description: 'Почта внешнего аккаунта' })
@IsOptional()
@IsString({ message: 'Почта внешнего аккаунта должна быть строкой' })
email?: string;
@ApiPropertyOptional({ description: 'Отображаемое имя внешнего аккаунта' })
@IsOptional()
@IsString({ message: 'Имя внешнего аккаунта должно быть строкой' })
displayName?: string;
@ApiPropertyOptional({ description: 'Аватар внешнего аккаунта' })
@IsOptional()
@IsString({ message: 'Аватар внешнего аккаунта должен быть строкой' })
avatarUrl?: string;
}

View File

@@ -0,0 +1,44 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { status as GrpcStatus } from '@grpc/grpc-js';
interface HttpResponseLike {
status(code: number): { json(body: unknown): unknown };
}
function grpcToHttp(code?: number): number {
switch (code) {
case GrpcStatus.INVALID_ARGUMENT:
case GrpcStatus.FAILED_PRECONDITION:
case GrpcStatus.OUT_OF_RANGE:
return 400;
case GrpcStatus.UNAUTHENTICATED:
return 401;
case GrpcStatus.PERMISSION_DENIED:
return 403;
case GrpcStatus.NOT_FOUND:
return 404;
case GrpcStatus.ALREADY_EXISTS:
return 409;
default:
return 500;
}
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<HttpResponseLike>();
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const payload = exception.getResponse();
response.status(statusCode).json(typeof payload === 'string' ? { statusCode, message: payload } : payload);
return;
}
const grpcError = exception as { code?: number; details?: string; message?: string };
const statusCode = grpcToHttp(grpcError?.code);
const message = grpcError?.details || grpcError?.message || 'Ошибка сервиса';
response.status(statusCode).json({ statusCode, message });
}
}

View File

@@ -0,0 +1,78 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
export interface AdminRequestUser {
id: string;
isSuperAdmin: boolean;
canAccessAdmin: boolean;
canManageRoles: boolean;
canManageOAuth: boolean;
canManageUsers: boolean;
canViewUsers: boolean;
canManageSettings: boolean;
roles: string[];
permissions: string[];
}
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly core: CoreGrpcService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{ headers: { authorization?: string }; adminUser?: AdminRequestUser }>();
let payload;
try {
payload = await verifyAccessToken(this.jwt, request.headers.authorization);
await assertSessionUnlocked(this.core, payload);
} catch (error) {
if (error instanceof UnauthorizedException || error instanceof ForbiddenException) {
throw error;
}
throw new UnauthorizedException('Недействительный токен доступа');
}
const profile = (await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }))) as AdminRequestUser & { id: string };
if (!profile.canAccessAdmin) {
throw new ForbiddenException('Доступ к админ-панели запрещён');
}
request.adminUser = {
id: profile.id,
isSuperAdmin: profile.isSuperAdmin,
canAccessAdmin: Boolean(profile.canAccessAdmin),
canManageRoles: Boolean(profile.canManageRoles),
canManageOAuth: Boolean(profile.canManageOAuth),
canManageUsers: Boolean(profile.canManageUsers),
canManageSettings: Boolean(profile.canManageSettings),
canViewUsers: Boolean(profile.canViewUsers),
roles: profile.roles ?? [],
permissions: profile.permissions ?? []
};
return true;
}
}
@Injectable()
export class SuperAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ adminUser?: AdminRequestUser }>();
if (!request.adminUser?.isSuperAdmin) {
throw new ForbiddenException('Только супер-администратор может выполнять это действие');
}
return true;
}
}
export function assertAdminPermission(user: AdminRequestUser | undefined, permission: keyof Pick<AdminRequestUser, 'canManageOAuth' | 'canManageUsers' | 'canManageSettings'>) {
if (!user?.[permission]) {
throw new ForbiddenException('Недостаточно прав для выполнения действия');
}
}

View File

@@ -0,0 +1,34 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './grpc-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: true, credentials: true });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
);
app.useGlobalFilters(new AllExceptionsFilter());
const config = new DocumentBuilder()
.setTitle('Lendry ID API')
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования Lendry ID.')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
swaggerOptions: { persistAuthorization: true },
customSiteTitle: 'Документация Lendry ID API'
});
await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000);
}
void bootstrap();

View File

@@ -0,0 +1,5 @@
export function buildContentDisposition(type: 'inline' | 'attachment', fileName: string) {
const asciiFallback = fileName.replace(/[^\x20-\x7E]+/g, '_').replace(/["\\]/g, '_') || 'file';
const encoded = encodeURIComponent(fileName);
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}

View File

@@ -0,0 +1,62 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { extractBearerToken } from './auth-token';
import { CoreGrpcService } from './core-grpc.service';
export interface AccessTokenPayload {
sub: string;
sessionId?: string;
pinVerified?: boolean;
isSuperAdmin?: boolean;
}
export async function verifyAccessToken(jwt: JwtService, authorization?: string): Promise<AccessTokenPayload> {
const token = extractBearerToken(authorization);
try {
return await jwt.verifyAsync<AccessTokenPayload>(token, {
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
issuer: 'id.lendry.ru'
});
} catch {
throw new UnauthorizedException({
statusCode: 401,
message: 'Токен доступа недействителен или истёк',
code: 'TOKEN_EXPIRED'
});
}
}
export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) {
if (!payload.sessionId) {
return;
}
const validation = (await firstValueFrom(
core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: true
})
)) as { requiresPin: boolean; sessionId: string };
if (validation.requiresPin) {
throw new ForbiddenException({
statusCode: 403,
message: 'Требуется подтверждение PIN-кода',
code: 'PIN_REQUIRED',
sessionId: validation.sessionId
});
}
}
export async function resolveAuthorizedPayload(
jwt: JwtService,
core: CoreGrpcService,
authorization?: string
): Promise<AccessTokenPayload> {
const payload = await verifyAccessToken(jwt, authorization);
await assertSessionUnlocked(core, payload);
return payload;
}