diff --git a/.gitignore b/.gitignore index 2a3bac1..aad8a99 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist .turbo .env .env.* +.cursorrules !.env.example docker-compose.override.yml .idp-install.json diff --git a/apps/sso-core b/apps/sso-core deleted file mode 160000 index 0192b0c..0000000 --- a/apps/sso-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0192b0c72ea372a4f4ff0132febe61e484f90375 diff --git a/apps/sso-core/.env.example b/apps/sso-core/.env.example new file mode 100644 index 0000000..cd3ee95 --- /dev/null +++ b/apps/sso-core/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL="postgresql://lendry:lendry_password@localhost:5432/lendry_id?schema=public" +JWT_ACCESS_SECRET="change-me-access" +JWT_REFRESH_SECRET="change-me-refresh" +REDIS_URL="redis://localhost:6379" +GRPC_URL="0.0.0.0:50051" diff --git a/apps/sso-core/Dockerfile b/apps/sso-core/Dockerfile new file mode 100644 index 0000000..57bdf8c --- /dev/null +++ b/apps/sso-core/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.4 + +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache openssl + +ARG NPM_REGISTRY=https://registry.npmjs.org + +COPY package.json package-lock.json .npmrc ./ +COPY apps/sso-core/package.json ./apps/sso-core/ +COPY apps/api-gateway/package.json ./apps/api-gateway/ +COPY apps/frontend/package.json ./apps/frontend/ +COPY apps/docs/package.json ./apps/docs/ + +RUN --mount=type=cache,target=/root/.npm \ + npm config set registry "${NPM_REGISTRY}" && \ + for i in 1 2 3 4 5; do \ + echo "npm ci: попытка $i/5" && \ + npm ci --no-audit --no-fund && exit 0; \ + echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \ + sleep $((i * 15)); \ + done; \ + echo "npm ci: все попытки исчерпаны"; \ + exit 1 + +COPY apps ./apps +COPY shared ./shared +COPY tsconfig.base.json ./ + +ENV DATABASE_URL="postgresql://lendry:lendry_password@postgres:5432/lendry_id?schema=public" +ENV JWT_ACCESS_SECRET="docker-access-secret" +ENV JWT_REFRESH_SECRET="docker-refresh-secret" +ENV DATA_ENCRYPTION_KEY="docker-data-encryption-secret-change-me" +ENV REDIS_URL="redis://redis:6379" +ENV GRPC_URL="0.0.0.0:50051" +ENV HTTP_PORT="3001" + +RUN npm --workspace @lendry/sso-core run prisma:generate \ + && npm --workspace @lendry/sso-core run proto:generate \ + && npm --workspace @lendry/sso-core run build + +EXPOSE 3001 50051 + +CMD ["sh", "-c", "npm --workspace @lendry/sso-core run prisma:push && node apps/sso-core/dist/main.js"] diff --git a/apps/sso-core/nest-cli.json b/apps/sso-core/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/sso-core/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/sso-core/package.json b/apps/sso-core/package.json new file mode 100644 index 0000000..42a5b96 --- /dev/null +++ b/apps/sso-core/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lendry/sso-core", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build && node scripts/copy-generated.mjs", + "start": "nest start", + "typecheck": "tsc --noEmit", + "prisma:validate": "prisma validate --schema prisma/schema.prisma", + "prisma:format": "prisma format --schema prisma/schema.prisma", + "prisma:generate": "prisma generate --schema prisma/schema.prisma", + "prisma:push": "prisma db push --schema prisma/schema.prisma", + "proto:generate": "node scripts/generate-proto-types.mjs" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1075.0", + "@aws-sdk/s3-request-presigner": "^3.1075.0", + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", + "@nestjs/common": "^11.1.9", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.9", + "@nestjs/jwt": "^11.0.2", + "@nestjs/microservices": "^11.1.9", + "@nestjs/platform-express": "^11.1.9", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.1.0", + "amqplib": "^0.10.9", + "bcryptjs": "^3.0.3", + "ioredis": "^5.8.2", + "pg": "^8.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "zod": "^4.2.1" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.14", + "@nestjs/schematics": "^11.0.9", + "@nestjs/testing": "^11.1.9", + "@types/amqplib": "^0.10.8", + "@types/node": "^24.10.1", + "@types/pg": "^8.20.0", + "grpc-tools": "^1.13.1", + "prisma": "^7.1.0", + "ts-node": "^10.9.2", + "ts-proto": "^2.11.8", + "typescript": "^5.9.3" + } +} diff --git a/apps/sso-core/prisma.config.ts b/apps/sso-core/prisma.config.ts new file mode 100644 index 0000000..ce002fb --- /dev/null +++ b/apps/sso-core/prisma.config.ts @@ -0,0 +1,12 @@ +import 'dotenv/config'; +import { defineConfig, env } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations' + }, + datasource: { + url: env('DATABASE_URL') + } +}); diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma new file mode 100644 index 0000000..0e78cc8 --- /dev/null +++ b/apps/sso-core/prisma/schema.prisma @@ -0,0 +1,481 @@ +generator client { + provider = "prisma-client-js" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +enum UserStatus { + ACTIVE + SUSPENDED + DELETED +} + +enum SessionStatus { + ACTIVE + LOCKED + REVOKED + EXPIRED +} + +enum DeviceType { + WEB + IOS + ANDROID + DESKTOP + TV + SMART_SPEAKER + OTHER +} + +enum OAuthClientType { + CONFIDENTIAL + PUBLIC +} + +model User { + id String @id @default(uuid()) + email String? @unique + phone String? @unique + backupEmail String? @unique + backupPhone String? @unique + passwordHash String? + displayName String + username String? @unique + avatarUrl String? + avatarStorageKey String? + birthDate DateTime? + gender String? + isSuperAdmin Boolean @default(false) + status UserStatus @default(ACTIVE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + pinCode PinCode? + sessions Session[] + devices Device[] + signInEvents SignInEvent[] + authCodes AuthCode[] + linkedAccounts LinkedAccount[] + userRoles UserRole[] + profile UserProfile? + documents UserDocument[] + addresses Address[] + oauthConsents OAuthConsent[] + oauthAuthCodes OAuthAuthorizationCode[] + ownedFamilyGroups FamilyGroup[] + familyMemberships FamilyMember[] + sentFamilyInvites FamilyInvite[] @relation("FamilyInviteInviter") + receivedFamilyInvites FamilyInvite[] @relation("FamilyInviteInvitee") + notifications Notification[] + chatRoomMembers ChatRoomMember[] + chatMessages ChatMessage[] + chatPollVotes ChatPollVote[] +} + +model UserProfile { + id String @id @default(uuid()) + userId String @unique + firstName String? + lastName String? + bio String? + age Int? + gender String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model PinCode { + id String @id @default(uuid()) + userId String @unique + hash String + isEnabled Boolean @default(false) + deletionRequestedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model Session { + id String @id @default(uuid()) + userId String + deviceId String? + refreshHash String + pinVerified Boolean @default(true) + status SessionStatus @default(ACTIVE) + ipAddress String? + userAgent String? + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull) + + @@index([userId, status]) +} + +model Device { + id String @id @default(uuid()) + userId String + name String + type DeviceType @default(OTHER) + fingerprint String + lastIp String? + lastSeenAt DateTime @default(now()) + trusted Boolean @default(false) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sessions Session[] + signInEvents SignInEvent[] + + @@unique([userId, fingerprint]) +} + +model SignInEvent { + id String @id @default(uuid()) + userId String + deviceId String? + ipAddress String? + userAgent String? + success Boolean + reason String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) +} + +model LinkedAccount { + id String @id @default(uuid()) + userId String + providerName String + providerId String + email String? + displayName String? + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([providerName, providerId]) + @@index([userId]) +} + +model Role { + id String @id @default(uuid()) + slug String @unique + name String + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users UserRole[] + permissions RolePermission[] +} + +model Permission { + id String @id @default(uuid()) + slug String @unique + name String + description String? + createdAt DateTime @default(now()) + roles RolePermission[] +} + +model UserRole { + userId String + roleId String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@id([userId, roleId]) +} + +model RolePermission { + roleId String + permissionId String + createdAt DateTime @default(now()) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) +} + +model OAuthClient { + id String @id @default(uuid()) + name String + clientId String @unique + clientSecret String? + type OAuthClientType @default(CONFIDENTIAL) + redirectUris String[] + scopes OAuthClientScope[] + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + consents OAuthConsent[] + authCodes OAuthAuthorizationCode[] +} + +model OAuthAuthorizationCode { + id String @id @default(uuid()) + code String @unique + userId String + clientId String + redirectUri String + scopes String[] + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@index([userId, clientId]) +} + +model OAuthScope { + id String @id @default(uuid()) + slug String @unique + name String + description String? + clients OAuthClientScope[] + consents OAuthConsentScope[] +} + +model OAuthClientScope { + clientId String + scopeId String + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + scope OAuthScope @relation(fields: [scopeId], references: [id], onDelete: Cascade) + + @@id([clientId, scopeId]) +} + +model OAuthConsent { + id String @id @default(uuid()) + userId String + clientId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + scopes OAuthConsentScope[] + + @@unique([userId, clientId]) +} + +model OAuthConsentScope { + consentId String + scopeId String + consent OAuthConsent @relation(fields: [consentId], references: [id], onDelete: Cascade) + scope OAuthScope @relation(fields: [scopeId], references: [id], onDelete: Cascade) + + @@id([consentId, scopeId]) +} + +model UserDocument { + id String @id @default(uuid()) + userId String + type String + number String + issuedAt DateTime? + expiresAt DateTime? + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model AuthCode { + id String @id @default(uuid()) + userId String? + channel String + target String + codeHash String + purpose String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([target, purpose, expiresAt]) +} + +model FamilyGroup { + id String @id @default(uuid()) + ownerId String + name String + avatarStorageKey String? + hasAvatar Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + members FamilyMember[] + invites FamilyInvite[] + chatRooms ChatRoom[] +} + +model FamilyInvite { + id String @id @default(uuid()) + groupId String + inviterId String + inviteeUserId String? + targetEmail String? + targetPhone String? + status String @default("PENDING") + token String @unique @default(uuid()) + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + inviter User @relation("FamilyInviteInviter", fields: [inviterId], references: [id], onDelete: Cascade) + invitee User? @relation("FamilyInviteInvitee", fields: [inviteeUserId], references: [id], onDelete: SetNull) + + @@index([inviteeUserId, status]) + @@index([groupId, status]) +} + +model Notification { + id String @id @default(uuid()) + userId String + type String + title String + message String + payload Json? + isRead Boolean @default(false) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, isRead, createdAt]) +} + +model ChatRoom { + id String @id @default(uuid()) + groupId String + type String + name String + avatarStorageKey String? + hasAvatar Boolean @default(false) + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + members ChatRoomMember[] + messages ChatMessage[] + + @@index([groupId, type]) +} + +model ChatRoomMember { + id String @id @default(uuid()) + roomId String + userId String + notificationsMuted Boolean @default(false) + joinedAt DateTime @default(now()) + room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roomId, userId]) +} + +model ChatMessage { + id String @id @default(uuid()) + roomId String + senderId String + type String + content String? + replyToId String? + metadata Json? + storageKey String? + mimeType String? + createdAt DateTime @default(now()) + editedAt DateTime? + room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) + poll ChatPoll? + + @@index([roomId, createdAt]) +} + +model ChatPoll { + id String @id @default(uuid()) + messageId String @unique + question String + allowsMultiple Boolean @default(false) + isAnonymous Boolean @default(false) + closedAt DateTime? + message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + options ChatPollOption[] +} + +model ChatPollOption { + id String @id @default(uuid()) + pollId String + text String + poll ChatPoll @relation(fields: [pollId], references: [id], onDelete: Cascade) + votes ChatPollVote[] +} + +model ChatPollVote { + id String @id @default(uuid()) + optionId String + userId String + option ChatPollOption @relation(fields: [optionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([optionId, userId]) +} + +model FamilyMember { + id String @id @default(uuid()) + groupId String + userId String + role String @default("member") + createdAt DateTime @default(now()) + group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId]) +} + +model Address { + id String @id @default(uuid()) + userId String + label String + city String + street String + house String + apartment String? + comment String? + latitude Float? + longitude Float? + fullAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, label]) +} + +model SystemSetting { + id String @id @default(uuid()) + key String @unique + value String + description String? + isSecret Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SocialProvider { + id String @id @default(uuid()) + providerName String @unique + clientId String + clientSecret String? + isEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/apps/sso-core/scripts/copy-generated.mjs b/apps/sso-core/scripts/copy-generated.mjs new file mode 100644 index 0000000..5ef9058 --- /dev/null +++ b/apps/sso-core/scripts/copy-generated.mjs @@ -0,0 +1,9 @@ +import { cp, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const source = resolve(root, 'src/generated'); +const target = resolve(root, 'dist/generated'); + +await mkdir(target, { recursive: true }); +await cp(source, target, { recursive: true, force: true }); diff --git a/apps/sso-core/scripts/generate-proto-types.mjs b/apps/sso-core/scripts/generate-proto-types.mjs new file mode 100644 index 0000000..9ff462a --- /dev/null +++ b/apps/sso-core/scripts/generate-proto-types.mjs @@ -0,0 +1,81 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const protoRoot = resolve(root, '../../shared/proto'); +const outputFile = resolve(root, 'src/generated/proto/contracts.ts'); + +const scalarMap = { + string: 'string', + bool: 'boolean', + int32: 'number', + int64: 'number', + uint32: 'number', + uint64: 'number', + double: 'number', + float: 'number' +}; + +function toTsType(type, namespace) { + return scalarMap[type] ?? `${namespace}${type}`; +} + +function parseMessages(proto, namespace) { + const messages = []; + const messageRegex = /message\s+(\w+)\s*\{([\s\S]*?)\}/g; + let match; + + while ((match = messageRegex.exec(proto))) { + const [, name, body] = match; + const fields = []; + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) continue; + const fieldMatch = /^(optional\s+)?(repeated\s+)?(\w+)\s+(\w+)\s*=\s*\d+;/.exec(trimmed); + if (!fieldMatch) continue; + const [, optional, repeated, type, fieldName] = fieldMatch; + const tsType = repeated ? `${toTsType(type, namespace)}[]` : toTsType(type, namespace); + fields.push(` ${fieldName}${optional ? '?' : ''}: ${tsType};`); + } + messages.push(`export interface ${namespace}${name} {\n${fields.join('\n')}\n}`); + } + + return messages; +} + +function parseServices(proto, namespace) { + const services = []; + const serviceRegex = /service\s+(\w+)\s*\{([\s\S]*?)\}/g; + let match; + + while ((match = serviceRegex.exec(proto))) { + const [, name, body] = match; + const methods = []; + for (const line of body.split('\n')) { + const trimmed = line.trim(); + const methodMatch = /^rpc\s+(\w+)\s+\((\w+)\)\s+returns\s+\((\w+)\);/.exec(trimmed); + if (!methodMatch) continue; + const [, methodName, request, response] = methodMatch; + methods.push(` ${methodName}(request: ${namespace}${request}): Promise<${namespace}${response}>;`); + } + services.push(`export interface ${namespace}${name}Client {\n${methods.join('\n')}\n}`); + } + + return services; +} + +const outputs = [ + '/* eslint-disable */', + '// Сгенерировано из shared/proto. Не редактируйте вручную.', + '' +]; + +for (const file of ['auth.proto', 'admin.proto', 'rbac.proto', 'security.proto', 'profile.proto', 'documents.proto', 'identity.proto']) { + const proto = await readFile(resolve(protoRoot, file), 'utf8'); + const packageMatch = /package\s+(\w+);/.exec(proto); + const namespace = packageMatch ? `${packageMatch[1][0].toUpperCase()}${packageMatch[1].slice(1)}` : ''; + outputs.push(...parseMessages(proto, namespace), ...parseServices(proto, namespace), ''); +} + +await mkdir(dirname(outputFile), { recursive: true }); +await writeFile(outputFile, `${outputs.join('\n\n')}\n`, 'utf8'); diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts new file mode 100644 index 0000000..956d0de --- /dev/null +++ b/apps/sso-core/src/app.module.ts @@ -0,0 +1,68 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { AccessService } from './domain/access.service'; +import { AdminSeedService } from './domain/admin-seed.service'; +import { AdminService } from './domain/admin.service'; +import { AuthGrpcController } from './domain/auth-grpc.controller'; +import { AuthService } from './domain/auth.service'; +import { PinService } from './domain/pin.service'; +import { RbacService } from './domain/rbac.service'; +import { SecurityService } from './domain/security.service'; +import { PrismaService } from './infra/prisma.service'; +import { RedisService } from './infra/redis.service'; +import { SystemSettingsSeedService } from './domain/system-settings.seed'; +import { SettingsService } from './domain/settings.service'; +import { SessionService } from './domain/session.service'; +import { HealthController } from './health.controller'; +import { EncryptionService } from './infra/encryption.service'; +import { ProfileService } from './domain/profile.service'; +import { DocumentsService } from './domain/documents.service'; +import { AddressesService } from './domain/addresses.service'; +import { OAuthCoreService } from './domain/oauth-core.service'; +import { OtpService } from './domain/otp.service'; +import { AdvancedAuthService } from './domain/advanced-auth.service'; +import { FamilyService } from './domain/family.service'; +import { MediaService } from './domain/media.service'; +import { NotificationsService } from './domain/notifications.service'; +import { ChatService } from './domain/chat.service'; +import { NotificationPublisherService } from './infra/notification-publisher.service'; +import { MinioService } from './infra/minio.service'; +import { LdapClientService } from './infra/ldap-client.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + JwtModule.register({}) + ], + controllers: [AuthGrpcController, HealthController], + providers: [ + PrismaService, + RedisService, + EncryptionService, + SystemSettingsSeedService, + SettingsService, + AuthService, + SessionService, + PinService, + AccessService, + AdminSeedService, + AdminService, + RbacService, + SecurityService, + ProfileService, + DocumentsService, + AddressesService, + OAuthCoreService, + OtpService, + AdvancedAuthService, + FamilyService, + MediaService, + NotificationsService, + ChatService, + NotificationPublisherService, + MinioService, + LdapClientService + ] +}) +export class AppModule {} diff --git a/apps/sso-core/src/domain/access.service.ts b/apps/sso-core/src/domain/access.service.ts new file mode 100644 index 0000000..3f90dbc --- /dev/null +++ b/apps/sso-core/src/domain/access.service.ts @@ -0,0 +1,90 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; + +export interface UserAccessContext { + roles: string[]; + permissions: string[]; + canAccessAdmin: boolean; + canManageRoles: boolean; + canManageOAuth: boolean; + canManageUsers: boolean; + canViewUsers: boolean; + canManageSettings: boolean; + canViewUserDocuments: boolean; +} + +@Injectable() +export class AccessService { + constructor(private readonly prisma: PrismaService) {} + + async getUserAccess(userId: string, isSuperAdmin: boolean): Promise { + if (isSuperAdmin) { + return { + roles: ['super-admin'], + permissions: ['admin.access', 'oauth.manage', 'users.manage', 'users.read', 'settings.manage', 'rbac.manage', 'documents.view_others'], + canAccessAdmin: true, + canManageRoles: true, + canManageOAuth: true, + canManageUsers: true, + canViewUsers: true, + canManageSettings: true, + canViewUserDocuments: true + }; + } + + const links = await this.prisma.userRole.findMany({ + where: { userId }, + include: { + role: { + include: { + permissions: { + include: { permission: true } + } + } + } + } + }); + + const roles = links.map((link) => link.role.slug); + const permissions = [...new Set(links.flatMap((link) => link.role.permissions.map((item) => item.permission.slug)))]; + + return { + roles, + permissions, + canAccessAdmin: permissions.includes('admin.access'), + canManageRoles: false, + canManageOAuth: permissions.includes('oauth.manage'), + canManageUsers: permissions.includes('users.manage'), + canViewUsers: permissions.includes('users.manage') || permissions.includes('users.read'), + canManageSettings: permissions.includes('settings.manage'), + canViewUserDocuments: permissions.includes('documents.view_others') + }; + } + + async assertSuperAdmin(actorUserId: string) { + const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } }); + if (!actor?.isSuperAdmin) { + throw new ForbiddenException('Только супер-администратор может выполнять это действие'); + } + } + + async assertPermission(userId: string, isSuperAdmin: boolean, permission: string) { + const access = await this.getUserAccess(userId, isSuperAdmin); + if (!access.permissions.includes(permission) && !isSuperAdmin) { + throw new Error('FORBIDDEN_PERMISSION'); + } + return access; + } + + async assertCanViewUserDocuments(requesterId: string, targetUserId: string) { + if (requesterId === targetUserId) return; + const requester = await this.prisma.user.findUnique({ where: { id: requesterId } }); + if (!requester) { + throw new ForbiddenException('Пользователь не найден'); + } + const access = await this.getUserAccess(requesterId, requester.isSuperAdmin); + if (!access.canViewUserDocuments) { + throw new ForbiddenException('Нет доступа к документам пользователя'); + } + } +} diff --git a/apps/sso-core/src/domain/addresses.service.ts b/apps/sso-core/src/domain/addresses.service.ts new file mode 100644 index 0000000..5a461b1 --- /dev/null +++ b/apps/sso-core/src/domain/addresses.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; + +export const ADDRESS_LABELS = ['HOME', 'WORK', 'OTHER'] as const; +export type AddressLabel = (typeof ADDRESS_LABELS)[number]; + +interface UpsertAddressCommand { + userId: string; + addressId?: string; + label: string; + city: string; + street: string; + house: string; + apartment?: string; + comment?: string; + latitude?: number; + longitude?: number; + fullAddress?: string; +} + +@Injectable() +export class AddressesService { + constructor(private readonly prisma: PrismaService) {} + + private assertLabel(label: string): asserts label is AddressLabel { + if (!ADDRESS_LABELS.includes(label as AddressLabel)) { + throw new BadRequestException('Некорректный тип адреса'); + } + } + + async list(userId: string) { + const addresses = await this.prisma.address.findMany({ + where: { userId }, + orderBy: [{ label: 'asc' }, { updatedAt: 'desc' }] + }); + return { addresses: addresses.map((address) => this.toResponse(address)) }; + } + + async upsert(command: UpsertAddressCommand) { + this.assertLabel(command.label); + + const city = command.city.trim(); + const street = command.street.trim(); + const house = command.house.trim(); + if (!city || !street || !house) { + throw new BadRequestException('Укажите город, улицу и дом'); + } + + const payload = { + label: command.label, + city, + street, + house, + apartment: command.apartment?.trim() || null, + comment: command.comment?.trim() || null, + latitude: command.latitude ?? null, + longitude: command.longitude ?? null, + fullAddress: command.fullAddress?.trim() || this.buildFullAddress(city, street, house, command.apartment) + }; + + if (command.addressId) { + const existing = await this.prisma.address.findFirst({ + where: { id: command.addressId, userId: command.userId } + }); + if (!existing) { + throw new NotFoundException('Адрес не найден'); + } + const address = await this.prisma.address.update({ + where: { id: command.addressId }, + data: payload + }); + return this.toResponse(address); + } + + if (command.label === 'HOME' || command.label === 'WORK') { + const existing = await this.prisma.address.findFirst({ + where: { userId: command.userId, label: command.label } + }); + if (existing) { + const address = await this.prisma.address.update({ + where: { id: existing.id }, + data: payload + }); + return this.toResponse(address); + } + } + + const address = await this.prisma.address.create({ + data: { + userId: command.userId, + ...payload + } + }); + return this.toResponse(address); + } + + async delete(addressId: string, userId: string) { + const existing = await this.prisma.address.findFirst({ + where: { id: addressId, userId } + }); + if (!existing) { + throw new NotFoundException('Адрес не найден'); + } + await this.prisma.address.delete({ where: { id: addressId } }); + return { count: 1 }; + } + + private buildFullAddress(city: string, street: string, house: string, apartment?: string) { + const parts = [`г. ${city}`, `${street}, ${house}`]; + if (apartment?.trim()) parts.push(`кв. ${apartment.trim()}`); + return parts.join(', '); + } + + private toResponse(address: { + id: string; + userId: string; + label: string; + city: string; + street: string; + house: string; + apartment: string | null; + comment: string | null; + latitude: number | null; + longitude: number | null; + fullAddress: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: address.id, + userId: address.userId, + label: address.label, + city: address.city, + street: address.street, + house: address.house, + apartment: address.apartment ?? undefined, + comment: address.comment ?? undefined, + latitude: address.latitude ?? undefined, + longitude: address.longitude ?? undefined, + fullAddress: address.fullAddress ?? undefined, + createdAt: address.createdAt.toISOString(), + updatedAt: address.updatedAt.toISOString() + }; + } +} diff --git a/apps/sso-core/src/domain/admin-seed.service.ts b/apps/sso-core/src/domain/admin-seed.service.ts new file mode 100644 index 0000000..0a7c949 --- /dev/null +++ b/apps/sso-core/src/domain/admin-seed.service.ts @@ -0,0 +1,93 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; +import { RbacService } from './rbac.service'; + +const PERMISSIONS = [ + { slug: 'admin.access', name: 'Доступ к админ-панели', description: 'Позволяет открывать раздел администрирования' }, + { slug: 'oauth.manage', name: 'Управление OAuth-приложениями', description: 'Создание и редактирование OAuth2-клиентов' }, + { slug: 'users.manage', name: 'Управление пользователями', description: 'Редактирование, блокировка и сброс паролей' }, + { slug: 'users.read', name: 'Просмотр пользователей', description: 'Чтение списка пользователей без изменений' }, + { slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' }, + { slug: 'rbac.manage', name: 'Управление ролями', description: 'Назначение ролей и прав (только супер-админ)' }, + { slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' } +] as const; + +const ROLES = [ + { + slug: 'admin', + name: 'Администратор', + description: 'Полный доступ к админ-панели без назначения ролей', + permissions: ['admin.access', 'oauth.manage', 'users.manage', 'settings.manage'] + }, + { + slug: 'moderator', + name: 'Модератор', + description: 'Просмотр пользователей и базовый доступ к админке', + permissions: ['admin.access', 'users.read'] + }, + { + slug: 'manager', + name: 'Менеджер', + description: 'Управление OAuth-приложениями', + permissions: ['admin.access', 'oauth.manage'] + } +] as const; + +const OAUTH_SCOPES = [ + { slug: 'openid', name: 'OpenID', description: 'Базовая идентификация OpenID Connect' }, + { slug: 'profile', name: 'Профиль', description: 'Имя, логин и аватар пользователя' }, + { slug: 'email', name: 'Email', description: 'Основная и резервная почта' }, + { slug: 'phone', name: 'Телефон', description: 'Основной и резервный телефон' }, + { slug: 'address', name: 'Адреса', description: 'Адреса пользователя' }, + { slug: 'documents', name: 'Документы', description: 'Документы пользователя' } +] as const; + +@Injectable() +export class AdminSeedService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly rbac: RbacService + ) {} + + async onModuleInit() { + for (const permission of PERMISSIONS) { + await this.rbac.upsertPermission(permission); + } + + for (const scope of OAUTH_SCOPES) { + await this.prisma.oAuthScope.upsert({ + where: { slug: scope.slug }, + create: scope, + update: { name: scope.name, description: scope.description } + }); + } + + for (const role of ROLES) { + const existing = await this.prisma.role.findUnique({ where: { slug: role.slug } }); + const roleRecord = + existing ?? + (await this.prisma.role.create({ + data: { + slug: role.slug, + name: role.name, + description: role.description + } + })); + + await this.prisma.role.update({ + where: { id: roleRecord.id }, + data: { name: role.name, description: role.description } + }); + + for (const slug of role.permissions) { + const permission = await this.prisma.permission.findUnique({ where: { slug } }); + if (!permission) continue; + await this.prisma.rolePermission.upsert({ + where: { roleId_permissionId: { roleId: roleRecord.id, permissionId: permission.id } }, + create: { roleId: roleRecord.id, permissionId: permission.id }, + update: {} + }); + } + } + } +} diff --git a/apps/sso-core/src/domain/admin.service.ts b/apps/sso-core/src/domain/admin.service.ts new file mode 100644 index 0000000..c37acf4 --- /dev/null +++ b/apps/sso-core/src/domain/admin.service.ts @@ -0,0 +1,136 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { UserStatus } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; +import { AccessService } from './access.service'; + +@Injectable() +export class AdminService { + constructor( + private readonly prisma: PrismaService, + private readonly access: AccessService + ) {} + + async listUsers(search?: string) { + const users = await this.prisma.user.findMany({ + where: search + ? { + OR: [ + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search, mode: 'insensitive' } }, + { displayName: { contains: search, mode: 'insensitive' } }, + { username: { contains: search, mode: 'insensitive' } } + ] + } + : undefined, + orderBy: { createdAt: 'desc' }, + include: { userRoles: { include: { role: true } } } + }); + + return users.map((user) => this.toAdminUser(user)); + } + + async updateUserProfile(userId: string, data: { displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) { + await this.ensureUserExists(userId); + const user = await this.prisma.user.update({ + where: { id: userId }, + data, + include: { userRoles: { include: { role: true } } } + }); + return this.toAdminUser(user); + } + + async resetPassword(userId: string, newPassword: string) { + if (newPassword.length < 8) { + throw new BadRequestException('Пароль должен содержать минимум 8 символов'); + } + + await this.ensureUserExists(userId); + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash: await bcrypt.hash(newPassword, 12) }, + include: { userRoles: { include: { role: true } } } + }); + return this.toAdminUser(user); + } + + async suspendUser(userId: string) { + await this.ensureUserExists(userId); + await this.prisma.session.updateMany({ where: { userId }, data: { status: 'REVOKED' } }); + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { status: UserStatus.SUSPENDED }, + include: { userRoles: { include: { role: true } } } + }); + return this.toAdminUser(user); + } + + async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) { + await this.access.assertSuperAdmin(actorUserId); + await this.ensureUserExists(userId); + + const target = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!target) { + throw new NotFoundException('Пользователь не найден'); + } + + if (target.isSuperAdmin && !isSuperAdmin) { + const superAdmins = await this.prisma.user.count({ where: { isSuperAdmin: true, status: UserStatus.ACTIVE } }); + if (superAdmins <= 1) { + throw new BadRequestException('Нельзя снять права у единственного супер-администратора'); + } + } + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { isSuperAdmin }, + include: { userRoles: { include: { role: true } } } + }); + return this.toAdminUser(user); + } + + async deleteUser(userId: string) { + await this.ensureUserExists(userId); + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { status: UserStatus.DELETED, deletedAt: new Date() }, + include: { userRoles: { include: { role: true } } } + }); + return this.toAdminUser(user); + } + + private toAdminUser(user: { + id: string; + email: string | null; + phone: string | null; + backupEmail: string | null; + backupPhone: string | null; + displayName: string; + username: string | null; + isSuperAdmin: boolean; + status: UserStatus; + createdAt: Date; + userRoles?: { role: { slug: string } }[]; + }) { + return { + id: user.id, + email: user.email ?? undefined, + phone: user.phone ?? undefined, + backupEmail: user.backupEmail ?? undefined, + backupPhone: user.backupPhone ?? undefined, + displayName: user.displayName, + username: user.username ?? undefined, + isSuperAdmin: user.isSuperAdmin, + status: user.status, + createdAt: user.createdAt.toISOString(), + roles: user.userRoles?.map((link) => link.role.slug) ?? [] + }; + } + + private async ensureUserExists(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Пользователь не найден'); + } + } +} diff --git a/apps/sso-core/src/domain/advanced-auth.service.ts b/apps/sso-core/src/domain/advanced-auth.service.ts new file mode 100644 index 0000000..8aab1ac --- /dev/null +++ b/apps/sso-core/src/domain/advanced-auth.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'node:crypto'; + +@Injectable() +export class AdvancedAuthService { + createWebAuthnRegistrationChallenge(userId: string) { + return this.challenge(`webauthn-register:${userId}`); + } + + createWebAuthnLoginChallenge(userId: string) { + return this.challenge(`webauthn-login:${userId}`); + } + + createQrSession() { + return { + sessionId: randomUUID(), + status: 'PENDING', + expiresAt: new Date(Date.now() + 2 * 60_000).toISOString() + }; + } + + pollQrSession(sessionId: string) { + return { + sessionId, + status: 'PENDING', + expiresAt: new Date(Date.now() + 2 * 60_000).toISOString() + }; + } + + private challenge(prefix: string) { + return { + challengeId: randomUUID(), + challenge: `${prefix}:${randomBytes(32).toString('base64url')}`, + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString() + }; + } +} diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts new file mode 100644 index 0000000..d9d3d8e --- /dev/null +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -0,0 +1,787 @@ +import { Controller, UseFilters } from '@nestjs/common'; +import { GrpcMethod } from '@nestjs/microservices'; +import { GrpcExceptionFilter } from '../infra/grpc-exception.filter'; +import { AdminService } from './admin.service'; +import { AuthService } from './auth.service'; +import { LoginCommand, RegisterCommand, VerifyPinCommand } from './dto'; +import { PinService } from './pin.service'; +import { SessionService } from './session.service'; +import { RbacService } from './rbac.service'; +import { SecurityService } from './security.service'; +import { SettingsService } from './settings.service'; +import { UserStatus } from '../generated/prisma/client'; +import { ProfileService } from './profile.service'; +import { DocumentsService } from './documents.service'; +import { AddressesService } from './addresses.service'; +import { OAuthCoreService } from './oauth-core.service'; +import { OtpService } from './otp.service'; +import { AdvancedAuthService } from './advanced-auth.service'; +import { FamilyService } from './family.service'; +import { MediaService } from './media.service'; +import { NotificationsService } from './notifications.service'; +import { ChatService } from './chat.service'; + +@Controller() +@UseFilters(new GrpcExceptionFilter()) +export class AuthGrpcController { + constructor( + private readonly auth: AuthService, + private readonly pin: PinService, + private readonly session: SessionService, + private readonly admin: AdminService, + private readonly rbac: RbacService, + private readonly security: SecurityService, + private readonly settings: SettingsService, + private readonly profile: ProfileService, + private readonly documents: DocumentsService, + private readonly addresses: AddressesService, + private readonly oauthCore: OAuthCoreService, + private readonly otp: OtpService, + private readonly advancedAuth: AdvancedAuthService, + private readonly family: FamilyService, + private readonly media: MediaService, + private readonly notifications: NotificationsService, + private readonly chat: ChatService + ) {} + + @GrpcMethod('AuthService', 'Register') + register(command: RegisterCommand) { + return this.auth.register(command); + } + + @GrpcMethod('AuthService', 'Login') + login(command: LoginCommand) { + return this.auth.login(command); + } + + @GrpcMethod('AuthService', 'Identify') + identify(command: { login: string }) { + return this.auth.identify(command.login); + } + + @GrpcMethod('AuthService', 'SendOtp') + sendPasswordlessOtp(command: { recipient: string; channel?: string }) { + return this.auth.sendOtp(command.recipient, command.channel); + } + + @GrpcMethod('AuthService', 'VerifyOtp') + verifyPasswordlessOtp(command: LoginCommand & { recipient: string; code: string }) { + return this.auth.verifyOtp(command); + } + + @GrpcMethod('AuthService', 'LoginWithPassword') + loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) { + return this.auth.loginWithPassword(command); + } + + @GrpcMethod('AuthService', 'LoginWithLdap') + loginWithLdap(command: { + username: string; + password: string; + fingerprint: string; + deviceName?: string; + deviceType?: string; + ipAddress?: string; + userAgent?: string; + }) { + return this.auth.loginWithLdap(command); + } + + @GrpcMethod('AuthService', 'VerifyPin') + verifyPin(command: VerifyPinCommand) { + return this.pin.verifyPin(command.sessionId, command.pin); + } + + @GrpcMethod('AuthService', 'GetMe') + getMe(command: { userId: string }) { + return this.auth.getMe(command.userId); + } + + @GrpcMethod('AuthService', 'RefreshSession') + refreshSession(command: { refreshToken: string; sessionId?: string }) { + return this.session.refreshSession(command.refreshToken, command.sessionId); + } + + @GrpcMethod('AuthService', 'ValidateSession') + validateSession(command: { userId: string; sessionId: string; touchActivity?: boolean }) { + return this.session.validateSession(command.userId, command.sessionId, command.touchActivity ?? false); + } + + @GrpcMethod('AdminService', 'ListUsers') + async listUsers(command: { search?: string }) { + const users = await this.admin.listUsers(command.search); + return { users }; + } + + @GrpcMethod('AdminService', 'SuspendUser') + suspendUser(command: { userId: string }) { + return this.admin.suspendUser(command.userId); + } + + @GrpcMethod('AdminService', 'UpdateUserProfile') + updateUserProfile(command: { userId: string; displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) { + const { userId, ...data } = command; + return this.admin.updateUserProfile(userId, data); + } + + @GrpcMethod('AdminService', 'ResetPassword') + resetPassword(command: { userId: string; newPassword: string }) { + return this.admin.resetPassword(command.userId, command.newPassword); + } + + @GrpcMethod('AdminService', 'SetSuperAdmin') + setSuperAdmin(command: { actorUserId: string; userId: string; isSuperAdmin: boolean }) { + return this.admin.setSuperAdmin(command.actorUserId, command.userId, command.isSuperAdmin); + } + + @GrpcMethod('RbacService', 'ListRoles') + async listRoles() { + const roles = await this.rbac.listRoles(); + return { + roles: roles.map((role) => ({ + id: role.id, + slug: role.slug, + name: role.name, + description: role.description ?? undefined, + permissions: role.permissions.map((link) => ({ + id: link.permission.id, + slug: link.permission.slug, + name: link.permission.name, + description: link.permission.description ?? undefined + })) + })) + }; + } + + @GrpcMethod('RbacService', 'ListOAuthClients') + async listOAuthClients() { + const clients = await this.rbac.listOAuthClients(); + return { + clients: clients.map((client) => ({ + id: client.id, + name: client.name, + clientId: client.clientId, + redirectUris: client.redirectUris, + scopes: client.scopes.map((link) => link.scope.slug), + isActive: client.isActive, + type: client.type + })) + }; + } + + @GrpcMethod('RbacService', 'ListPermissions') + async listPermissions() { + const permissions = await this.rbac.listPermissions(); + return { + permissions: permissions.map((permission) => ({ + id: permission.id, + slug: permission.slug, + name: permission.name, + description: permission.description ?? undefined + })) + }; + } + + @GrpcMethod('RbacService', 'ListOAuthScopes') + async listOAuthScopes() { + const scopes = await this.rbac.listOAuthScopes(); + return { + scopes: scopes.map((scope) => ({ + id: scope.id, + slug: scope.slug, + name: scope.name, + description: scope.description ?? undefined + })) + }; + } + + @GrpcMethod('RbacService', 'CreateRole') + async createRole(command: { actorUserId: string; slug: string; name: string; description?: string; permissionSlugs?: string[] }) { + const role = await this.rbac.createRole(command.actorUserId, { + slug: command.slug, + name: command.name, + description: command.description, + permissionSlugs: command.permissionSlugs + }); + return { + id: role.id, + slug: role.slug, + name: role.name, + description: role.description ?? undefined, + permissions: role.permissions.map((link) => ({ + id: link.permission.id, + slug: link.permission.slug, + name: link.permission.name, + description: link.permission.description ?? undefined + })) + }; + } + + @GrpcMethod('RbacService', 'AssignUserRole') + async assignUserRole(command: { actorUserId: string; userId: string; roleSlug: string }) { + const roles = await this.rbac.assignRole(command.actorUserId, command.userId, command.roleSlug); + return { userId: command.userId, roles }; + } + + @GrpcMethod('RbacService', 'RemoveUserRole') + async removeUserRole(command: { actorUserId: string; userId: string; roleSlug: string }) { + await this.rbac.removeRole(command.actorUserId, command.userId, command.roleSlug); + return {}; + } + + @GrpcMethod('RbacService', 'CreateOAuthClient') + async createOAuthClient(command: { actorUserId: string; name: string; redirectUris: string[]; scopes: string[]; type?: string }) { + const { client, clientSecret } = await this.rbac.createOAuthClient(command.actorUserId, { + name: command.name, + redirectUris: command.redirectUris, + scopes: command.scopes, + type: command.type as never + }); + return { + id: client.id, + name: client.name, + clientId: client.clientId, + redirectUris: client.redirectUris, + scopes: client.scopes.map((link) => link.scope.slug), + isActive: client.isActive, + type: client.type, + clientSecret: clientSecret ?? undefined + }; + } + + @GrpcMethod('RbacService', 'UpdateOAuthClient') + async updateOAuthClient(command: { + actorUserId: string; + clientId: string; + name?: string; + redirectUris?: string[]; + scopes?: string[]; + isActive?: boolean; + }) { + const { client } = await this.rbac.updateOAuthClient(command.actorUserId, command.clientId, { + name: command.name, + redirectUris: command.redirectUris, + scopes: command.scopes, + isActive: command.isActive + }); + return { + id: client.id, + name: client.name, + clientId: client.clientId, + redirectUris: client.redirectUris, + scopes: client.scopes.map((link) => link.scope.slug), + isActive: client.isActive, + type: client.type + }; + } + + @GrpcMethod('RbacService', 'RotateOAuthSecret') + async rotateOAuthSecret(command: { actorUserId: string; clientId: string }) { + return this.rbac.rotateOAuthSecret(command.actorUserId, command.clientId); + } + + @GrpcMethod('SecurityService', 'ListActiveDevices') + async listActiveDevices(command: { userId: string }) { + const devices = await this.security.listActiveDevices(command.userId); + return { + devices: devices.map((device) => ({ + id: device.id, + name: device.name, + type: device.type, + lastIp: device.lastIp, + lastSeenAt: device.lastSeenAt.toISOString(), + trusted: device.trusted + })) + }; + } + + @GrpcMethod('SecurityService', 'ListSignInHistory') + async listSignInHistory(command: { userId: string }) { + const events = await this.security.listSignInHistory(command.userId); + return { + events: events.map((event) => ({ + id: event.id, + success: event.success, + reason: event.reason, + ipAddress: event.ipAddress, + userAgent: event.userAgent, + createdAt: event.createdAt.toISOString() + })) + }; + } + + @GrpcMethod('SecurityService', 'ListActiveSessions') + async listActiveSessions(command: { userId: string }) { + const sessions = await this.security.listActiveSessions(command.userId); + return { + sessions: sessions.map((session) => this.toSessionResponse(session)) + }; + } + + @GrpcMethod('SecurityService', 'LockSession') + async lockSession(command: { userId: string; sessionId: string }) { + return this.toSessionResponse(await this.security.lockSession(command.userId, command.sessionId)); + } + + @GrpcMethod('SecurityService', 'RevokeSession') + async revokeSession(command: { userId: string; sessionId: string }) { + return this.toSessionResponse(await this.security.revokeSession(command.userId, command.sessionId)); + } + + @GrpcMethod('SecurityService', 'RevokeDevice') + async revokeDevice(command: { userId: string; deviceId: string }) { + const result = await this.security.revokeDevice(command.userId, command.deviceId); + return { count: result.count }; + } + + @GrpcMethod('SecurityService', 'RevokeAllSessions') + async revokeAllSessions(command: { userId: string }) { + const result = await this.security.revokeAllSessions(command.userId); + return { count: result.count }; + } + + @GrpcMethod('SecurityService', 'SetupPin') + setupPin(command: { userId: string; pin: string }) { + return this.pin.setupPin(command.userId, command.pin); + } + + @GrpcMethod('SecurityService', 'UpdatePin') + updatePin(command: { userId: string; pin: string }) { + return this.pin.updatePin(command.userId, command.pin); + } + + @GrpcMethod('SecurityService', 'RequestPinDeletion') + requestPinDeletion(command: { userId: string; pin: string }) { + return this.pin.requestPinDeletion(command.userId, command.pin); + } + + @GrpcMethod('SecurityService', 'CancelPinDeletion') + cancelPinDeletion(command: { userId: string }) { + return this.pin.cancelPinDeletion(command.userId); + } + + @GrpcMethod('SecurityService', 'VerifyPinCode') + verifyPinCode(command: { sessionId: string; pin: string }) { + return this.pin.verifyPin(command.sessionId, command.pin); + } + + @GrpcMethod('SecurityService', 'ConnectLinkedAccount') + async connectLinkedAccount(command: { userId: string; providerName: string; providerId: string; email?: string; displayName?: string; avatarUrl?: string }) { + return this.toLinkedAccountResponse(await this.security.connectSocialAccount(command.userId, command)); + } + + @GrpcMethod('SecurityService', 'DisconnectLinkedAccount') + disconnectLinkedAccount(command: { userId: string; providerName: string }) { + return this.security.disconnectSocialAccount(command.userId, command.providerName); + } + + @GrpcMethod('SecurityService', 'ListLinkedAccounts') + async listLinkedAccounts(command: { userId: string }) { + const accounts = await this.security.listLinkedAccounts(command.userId); + return { accounts: accounts.map((account) => this.toLinkedAccountResponse(account)) }; + } + + @GrpcMethod('SettingsService', 'ListSettings') + async listSettings() { + const settings = await this.settings.list(); + return { + settings: settings.map((setting) => ({ + id: setting.id, + key: setting.key, + value: setting.value, + description: setting.description ?? undefined, + isSecret: setting.isSecret + })) + }; + } + + @GrpcMethod('SettingsService', 'ListPublicSettings') + async listPublicSettings() { + const settings = await this.settings.listPublic(); + return { + settings: settings.map((setting) => ({ + key: setting.key, + value: setting.value + })) + }; + } + + @GrpcMethod('SettingsService', 'GetSetting') + getSetting(command: { key: string }) { + return this.settings.get(command.key); + } + + @GrpcMethod('SettingsService', 'UpsertSetting') + upsertSetting(command: { key: string; value: string; description?: string; isSecret?: boolean }) { + return this.settings.upsert(command); + } + + @GrpcMethod('SettingsService', 'DeleteSetting') + deleteSetting(command: { key: string }) { + return this.settings.delete(command.key); + } + + @GrpcMethod('SettingsService', 'ListSocialProviders') + async listSocialProviders() { + const providers = await this.settings.listSocialProviders(); + return { providers: providers.map((provider) => this.toSocialProviderResponse(provider)) }; + } + + @GrpcMethod('SettingsService', 'UpsertSocialProvider') + async upsertSocialProvider(command: { providerName: string; clientId: string; clientSecret?: string; isEnabled: boolean }) { + return this.toSocialProviderResponse(await this.settings.upsertSocialProvider(command)); + } + + @GrpcMethod('SettingsService', 'DeleteSocialProvider') + deleteSocialProvider(command: { providerName: string }) { + return this.settings.deleteSocialProvider(command.providerName); + } + + @GrpcMethod('ProfileService', 'GetProfile') + getProfile(command: { userId: string }) { + return this.profile.getProfile(command.userId); + } + + @GrpcMethod('ProfileService', 'UpdateAvatar') + updateAvatar(command: { userId: string; avatarUrl?: string; avatarStorageKey?: string }) { + return this.profile.updateAvatar(command.userId, command.avatarUrl, command.avatarStorageKey); + } + + @GrpcMethod('ProfileService', 'UpdateProfile') + updateProfile(command: { userId: string; firstName?: string; lastName?: string; bio?: string; age?: number; gender?: string }) { + return this.profile.updateProfile(command); + } + + @GrpcMethod('ProfileService', 'UpdateContacts') + updateContacts(command: { userId: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string }) { + return this.profile.updateContacts(command); + } + + @GrpcMethod('ProfileService', 'SetPassword') + setPassword(command: { userId: string; password: string }) { + return this.profile.setPassword(command.userId, command.password); + } + + @GrpcMethod('ProfileService', 'SoftDeleteProfile') + softDeleteProfile(command: { userId: string }) { + return this.profile.softDeleteProfile(command.userId); + } + + @GrpcMethod('DocumentsService', 'CreateDocument') + createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) { + return this.documents.create(command); + } + + @GrpcMethod('DocumentsService', 'ListDocuments') + listDocuments(command: { userId: string }) { + return this.documents.list(command.userId); + } + + @GrpcMethod('DocumentsService', 'GetDocument') + getDocument(command: { documentId: string; userId: string }) { + return this.documents.get(command.documentId, command.userId); + } + + @GrpcMethod('DocumentsService', 'UpdateDocument') + updateDocument(command: { documentId: string; userId: string; number?: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) { + return this.documents.update(command); + } + + @GrpcMethod('DocumentsService', 'DeleteDocument') + deleteDocument(command: { documentId: string; userId: string }) { + return this.documents.delete(command.documentId, command.userId); + } + + @GrpcMethod('AddressesService', 'ListAddresses') + listAddresses(command: { userId: string }) { + return this.addresses.list(command.userId); + } + + @GrpcMethod('AddressesService', 'UpsertAddress') + upsertAddress(command: { + userId: string; + addressId?: string; + label: string; + city: string; + street: string; + house: string; + apartment?: string; + comment?: string; + latitude?: number; + longitude?: number; + fullAddress?: string; + }) { + return this.addresses.upsert(command); + } + + @GrpcMethod('AddressesService', 'DeleteAddress') + deleteAddress(command: { addressId: string; userId: string }) { + return this.addresses.delete(command.addressId, command.userId); + } + + @GrpcMethod('MediaService', 'CreateAvatarUploadUrl') + createAvatarUploadUrl(command: { userId: string; contentType: string }) { + return this.media.createAvatarUploadUrl(command.userId, command.contentType); + } + + @GrpcMethod('MediaService', 'ConfirmAvatar') + confirmAvatar(command: { userId: string; storageKey: string }) { + return this.media.confirmAvatar(command.userId, command.storageKey); + } + + @GrpcMethod('MediaService', 'GetAvatarAccessUrl') + getAvatarAccessUrl(command: { requesterId: string; targetUserId: string }) { + return this.media.getAvatarAccessUrl(command.requesterId, command.targetUserId); + } + + @GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl') + createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) { + return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType); + } + + @GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl') + getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) { + return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey); + } + + @GrpcMethod('MediaService', 'ResolveStreamToken') + resolveStreamToken(command: { token: string; requesterId?: string }) { + return this.media.resolveStreamToken(command.token, command.requesterId); + } + + @GrpcMethod('MediaService', 'CreateFamilyAvatarUploadUrl') + createFamilyAvatarUploadUrl(command: { requesterId: string; groupId: string; contentType: string }) { + return this.media.createFamilyAvatarUploadUrl(command.requesterId, command.groupId, command.contentType); + } + + @GrpcMethod('MediaService', 'ConfirmFamilyAvatar') + confirmFamilyAvatar(command: { requesterId: string; groupId: string; storageKey: string }) { + return this.media.confirmFamilyAvatar(command.requesterId, command.groupId, command.storageKey); + } + + @GrpcMethod('MediaService', 'GetFamilyAvatarAccessUrl') + getFamilyAvatarAccessUrl(command: { requesterId: string; groupId: string }) { + return this.media.getFamilyAvatarAccessUrl(command.requesterId, command.groupId); + } + + @GrpcMethod('MediaService', 'CreateChatMediaUploadUrl') + createChatMediaUploadUrl(command: { requesterId: string; roomId: string; contentType: string; fileName?: string }) { + return this.media.createChatMediaUploadUrl(command.requesterId, command.roomId, command.contentType, command.fileName); + } + + @GrpcMethod('MediaService', 'GetChatMediaAccessUrl') + getChatMediaAccessUrl(command: { requesterId: string; roomId: string; storageKey: string; fileName?: string }) { + return this.media.getChatMediaAccessUrl(command.requesterId, command.roomId, command.storageKey, command.fileName); + } + + @GrpcMethod('OAuthCoreService', 'Authorize') + authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) { + return this.oauthCore.authorize(command); + } + + @GrpcMethod('OAuthCoreService', 'Token') + token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) { + return this.oauthCore.token(command); + } + + @GrpcMethod('OAuthCoreService', 'UserInfo') + userInfo(command: { accessToken: string }) { + return this.oauthCore.userInfo(command.accessToken); + } + + @GrpcMethod('OtpService', 'SendOtp') + sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) { + return this.otp.send(command); + } + + @GrpcMethod('OtpService', 'VerifyOtp') + verifyOtp(command: { target: string; code: string; purpose: string }) { + return this.otp.verify(command); + } + + @GrpcMethod('AdvancedAuthService', 'CreateWebAuthnRegistrationChallenge') + createWebAuthnRegistrationChallenge(command: { userId: string }) { + return this.advancedAuth.createWebAuthnRegistrationChallenge(command.userId); + } + + @GrpcMethod('AdvancedAuthService', 'CreateWebAuthnLoginChallenge') + createWebAuthnLoginChallenge(command: { userId: string }) { + return this.advancedAuth.createWebAuthnLoginChallenge(command.userId); + } + + @GrpcMethod('AdvancedAuthService', 'CreateQrSession') + createQrSession(command: { deviceName: string }) { + return this.advancedAuth.createQrSession(); + } + + @GrpcMethod('AdvancedAuthService', 'PollQrSession') + pollQrSession(command: { sessionId: string }) { + return this.advancedAuth.pollQrSession(command.sessionId); + } + + @GrpcMethod('FamilyService', 'CreateFamilyGroup') + createFamilyGroup(command: { ownerId: string; name: string }) { + return this.family.create(command.ownerId, command.name); + } + + @GrpcMethod('FamilyService', 'ListFamilyGroups') + listFamilyGroups(command: { userId: string }) { + return this.family.list(command.userId); + } + + @GrpcMethod('FamilyService', 'GetFamilyGroup') + getFamilyGroup(command: { requesterId: string; groupId: string }) { + return this.family.get(command.requesterId, command.groupId); + } + + @GrpcMethod('FamilyService', 'UpdateFamilyGroup') + updateFamilyGroup(command: { requesterId: string; groupId: string; name: string }) { + return this.family.update(command.requesterId, command.groupId, command.name); + } + + @GrpcMethod('FamilyService', 'DeleteFamilyGroup') + deleteFamilyGroup(command: { requesterId: string; groupId: string }) { + return this.family.delete(command.requesterId, command.groupId); + } + + @GrpcMethod('FamilyService', 'AddFamilyMember') + addFamilyMember(command: { groupId: string; userId: string; role: string }) { + return this.family.addMember(command.groupId, command.userId, command.role); + } + + @GrpcMethod('FamilyService', 'RemoveFamilyMember') + removeFamilyMember(command: { requesterId: string; memberId: string }) { + return this.family.removeMember(command.requesterId, command.memberId); + } + + @GrpcMethod('FamilyService', 'SendFamilyInvite') + sendFamilyInvite(command: { requesterId: string; groupId: string; target: string }) { + return this.family.sendInvite(command.requesterId, command.groupId, command.target); + } + + @GrpcMethod('FamilyService', 'RespondFamilyInvite') + respondFamilyInvite(command: { userId: string; inviteId: string; accept: boolean }) { + return this.family.respondInvite(command.userId, command.inviteId, command.accept); + } + + @GrpcMethod('FamilyService', 'ListFamilyInvites') + listFamilyInvites(command: { userId: string; groupId?: string }) { + return this.family.listInvites(command.userId, command.groupId); + } + + @GrpcMethod('NotificationsService', 'ListNotifications') + listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) { + return this.notifications.list(command.userId, command.limit, command.unreadOnly); + } + + @GrpcMethod('NotificationsService', 'GetUnreadCount') + getUnreadCount(command: { userId: string }) { + return this.notifications.unreadCount(command.userId); + } + + @GrpcMethod('NotificationsService', 'MarkNotificationRead') + markNotificationRead(command: { userId: string; notificationId: string }) { + return this.notifications.markRead(command.userId, command.notificationId); + } + + @GrpcMethod('NotificationsService', 'MarkAllNotificationsRead') + markAllNotificationsRead(command: { userId: string }) { + return this.notifications.markAllRead(command.userId); + } + + @GrpcMethod('NotificationsService', 'DeleteNotification') + deleteNotification(command: { userId: string; notificationId: string }) { + return this.notifications.delete(command.userId, command.notificationId); + } + + @GrpcMethod('NotificationsService', 'DeleteAllNotifications') + deleteAllNotifications(command: { userId: string }) { + return this.notifications.deleteAll(command.userId); + } + + @GrpcMethod('ChatService', 'ListRooms') + listChatRooms(command: { userId: string; groupId: string }) { + return this.chat.listRooms(command.userId, command.groupId); + } + + @GrpcMethod('ChatService', 'CreateRoom') + createChatRoom(command: { userId: string; groupId: string; name: string; memberUserIds?: string[] }) { + return this.chat.createRoom(command.userId, command.groupId, command.name, command.memberUserIds ?? []); + } + + @GrpcMethod('ChatService', 'UpdateRoomSettings') + updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) { + return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted); + } + + @GrpcMethod('ChatService', 'ListMessages') + listChatMessages(command: { userId: string; roomId: string; beforeMessageId?: string; limit?: number }) { + return this.chat.listMessages(command.userId, command.roomId, command.beforeMessageId, command.limit); + } + + @GrpcMethod('ChatService', 'SendMessage') + sendChatMessage(command: { + userId: string; + roomId: string; + type: string; + content?: string; + replyToId?: string; + storageKey?: string; + mimeType?: string; + metadataJson?: string; + poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean }; + }) { + return this.chat.sendMessage( + command.userId, + command.roomId, + command.type, + command.content, + command.replyToId, + command.storageKey, + command.mimeType, + command.metadataJson, + command.poll + ); + } + + @GrpcMethod('ChatService', 'VotePoll') + voteChatPoll(command: { userId: string; messageId: string; optionIds?: string[] }) { + return this.chat.votePoll(command.userId, command.messageId, command.optionIds ?? []); + } + + @GrpcMethod('ChatService', 'SetRoomNotificationsMuted') + setRoomNotificationsMuted(command: { userId: string; roomId: string; muted: boolean }) { + return this.chat.setRoomNotificationsMuted(command.userId, command.roomId, command.muted); + } + + private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) { + return { + id: session.id, + userId: session.userId, + deviceId: session.deviceId, + pinVerified: session.pinVerified, + status: session.status, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + expiresAt: session.expiresAt.toISOString(), + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString() + }; + } + + private toLinkedAccountResponse(account: { id: string; userId: string; providerName: string; providerId: string; email: string | null; displayName: string | null; avatarUrl: string | null; createdAt: Date; updatedAt: Date }) { + return { + ...account, + createdAt: account.createdAt.toISOString(), + updatedAt: account.updatedAt.toISOString() + }; + } + + private toSocialProviderResponse(provider: { id: string; providerName: string; clientId: string; clientSecret: string | null; isEnabled: boolean; createdAt: Date; updatedAt: Date }) { + return { + ...provider, + createdAt: provider.createdAt.toISOString(), + updatedAt: provider.updatedAt.toISOString() + }; + } +} diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts new file mode 100644 index 0000000..caa81e6 --- /dev/null +++ b/apps/sso-core/src/domain/auth.service.ts @@ -0,0 +1,519 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import { randomBytes, randomInt } from 'node:crypto'; +import { DeviceType, Prisma, SessionStatus, User, UserStatus } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; +import { RedisService } from '../infra/redis.service'; +import { LdapClientService, LdapConnectionConfig } from '../infra/ldap-client.service'; +import { normalizeLdapUserFilter, resolveLdapBindIdentity, resolveLdapPort, validateLdapConfig } from '../infra/ldap-config.util'; +import { AuthTokens, LoginCommand, PublicUser, RegisterCommand } from './dto'; +import { AccessService } from './access.service'; +import { SettingsService } from './settings.service'; + +const FIRST_ADMIN_LOCK = 'locks:first-super-admin'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly access: AccessService, + private readonly settings: SettingsService, + private readonly ldapClient: LdapClientService + ) {} + + async register(command: RegisterCommand): Promise { + if (!command.email && !command.phone) { + throw new BadRequestException('Укажите почту или телефон'); + } + + const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null; + const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); + + if (!lockAcquired) { + throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); + } + + try { + const user = await this.prisma.$transaction( + async (tx) => { + const usersCount = await tx.user.count({ where: { deletedAt: null } }); + return tx.user.create({ + data: { + email: command.email, + phone: command.phone, + passwordHash, + displayName: command.displayName, + username: command.username, + isSuperAdmin: usersCount === 0 + } + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + + return await this.toPublicUser(user); + } catch (error) { + if (this.isUniqueError(error)) { + throw new BadRequestException('Пользователь с такими данными уже существует'); + } + throw error; + } finally { + await this.redis.releaseLock(FIRST_ADMIN_LOCK); + } + } + + async login(command: LoginCommand): Promise { + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ email: command.login }, { phone: command.login }, { username: command.login }], + deletedAt: null, + status: UserStatus.ACTIVE + }, + include: { pinCode: true } + }); + + if (!user || user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('Неверный логин или пароль'); + } + + if (!user.passwordHash) { + throw new UnauthorizedException('Для этого аккаунта используйте вход по коду'); + } + + const passwordMatches = await bcrypt.compare(command.password, user.passwordHash); + if (!passwordMatches) { + await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль'); + throw new UnauthorizedException('Неверный логин или пароль'); + } + + const device = await this.upsertDevice(user.id, command); + + const auth = await this.createAuthTokens(user, device.id, command); + await this.writeSignInEvent(user.id, true, command, device.id); + return auth; + } + + async identify(login: string) { + const user = await this.findUserByRecipient(login); + const methods: Array<{ kind: string; channel: string; masked: string }> = []; + + if (user?.passwordHash) { + methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' }); + } + + if (user) { + const channels: Array<{ channel: string; value: string | null }> = [ + { channel: 'email', value: user.email }, + { channel: 'phone', value: user.phone }, + { channel: 'backupEmail', value: user.backupEmail }, + { channel: 'backupPhone', value: user.backupPhone } + ]; + for (const item of channels) { + if (item.value && item.value !== login) { + methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) }); + } + } + } + + return { + exists: Boolean(user), + hasPassword: Boolean(user?.passwordHash), + isPinEnabled: Boolean(user?.pinCode?.isEnabled), + methods + }; + } + + async sendOtp(recipient: string, channel?: string) { + const existingUser = await this.findUserByRecipient(recipient); + const target = this.resolveOtpTarget(recipient, channel, existingUser); + const code = String(randomInt(100000, 999999)); + const expiresAt = new Date(Date.now() + 5 * 60_000); + await this.prisma.authCode.create({ + data: { + userId: existingUser?.id, + target, + channel: target.includes('@') ? 'email' : 'sms', + purpose: 'passwordless-login', + codeHash: await bcrypt.hash(code, 10), + expiresAt + } + }); + console.log('[OTP MOCK] Code for', target, 'is:', code); + return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) }; + } + + async verifyOtp(command: LoginCommand & { recipient: string; code: string }) { + const existingUser = await this.findUserByRecipient(command.recipient); + const authCode = await this.prisma.authCode.findFirst({ + where: { + purpose: 'passwordless-login', + usedAt: null, + expiresAt: { gt: new Date() }, + OR: [{ target: command.recipient }, ...(existingUser ? [{ userId: existingUser.id }] : [])] + }, + orderBy: { createdAt: 'desc' } + }); + + if (!authCode) throw new UnauthorizedException('Код входа не найден или истек'); + const matches = await bcrypt.compare(command.code, authCode.codeHash); + if (!matches) throw new UnauthorizedException('Неверный код входа'); + await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } }); + + const user = await this.findOrCreatePasswordlessUser(command.recipient); + + const device = await this.upsertDevice(user.id, command); + const auth = await this.createAuthTokens(user, device.id, command); + await this.writeSignInEvent(user.id, true, { ...command, login: command.recipient, password: '' }, device.id); + return { requiresPassword: false, tempAuthToken: undefined, auth }; + } + + private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) { + if (!channel || channel === 'primary' || !user) return recipient; + const map: Record = { + email: user.email, + phone: user.phone, + backupEmail: user.backupEmail, + backupPhone: user.backupPhone + }; + return map[channel] ?? recipient; + } + + private maskTarget(value: string) { + if (value.includes('@')) { + const [name, domain] = value.split('@'); + const visible = name.slice(0, 1); + return `${visible}${'*'.repeat(Math.max(name.length - 1, 1))}@${domain}`; + } + const tail = value.slice(-2); + const head = value.slice(0, value.startsWith('+') ? 2 : 1); + return `${head}${'*'.repeat(Math.max(value.length - head.length - 2, 2))}${tail}`; + } + + async loginWithPassword(command: LoginCommand & { tempAuthToken?: string }) { + const user = command.tempAuthToken + ? await this.findUserByTempPasswordToken(command.tempAuthToken) + : await this.findUserByRecipient(command.login); + if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован'); + const matches = await bcrypt.compare(command.password, user.passwordHash); + if (!matches) { + await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль'); + throw new UnauthorizedException('Неверный пароль'); + } + const device = await this.upsertDevice(user.id, command); + const auth = await this.createAuthTokens(user, device.id, command); + await this.writeSignInEvent(user.id, true, command, device.id); + return auth; + } + + async loginWithLdap(command: { + username: string; + password: string; + fingerprint: string; + deviceName?: string; + deviceType?: string; + ipAddress?: string; + userAgent?: string; + }): Promise { + const enabled = await this.settings.getBoolean('LDAP_ENABLED', false); + if (!enabled) { + throw new UnauthorizedException('LDAP-аутентификация отключена'); + } + + const ldapConfig = await this.getLdapConfig(); + validateLdapConfig(ldapConfig); + let ldapResult; + try { + ldapResult = await this.ldapClient.authenticate(ldapConfig, command.username, command.password); + } catch (error) { + throw new UnauthorizedException(error instanceof Error ? error.message : 'Неверный логин или пароль LDAP'); + } + + const user = await this.findOrCreateLdapUser(ldapResult); + const device = await this.upsertDevice(user.id, command); + const auth = await this.createAuthTokens(user, device.id, command); + await this.writeSignInEvent( + user.id, + true, + { login: command.username, password: '', fingerprint: command.fingerprint, deviceName: command.deviceName, deviceType: command.deviceType, ipAddress: command.ipAddress, userAgent: command.userAgent }, + device.id + ); + return auth; + } + + private async getLdapConfig(): Promise { + const useLdaps = await this.settings.getBoolean('LDAP_USE_LDAPS', false); + const rawPort = await this.settings.getNumber('LDAP_PORT', useLdaps ? 636 : 389); + const domain = await this.settings.getValue('LDAP_DOMAIN', ''); + const bindUsername = await this.settings.getValue('LDAP_BIND_USERNAME', ''); + const bindDnRaw = await this.settings.getValue('LDAP_BIND_DN', ''); + const bindDn = resolveLdapBindIdentity(bindUsername, bindDnRaw, domain); + return { + host: await this.settings.getValue('LDAP_HOST', ''), + port: resolveLdapPort(rawPort, useLdaps), + useLdaps, + skipTlsVerify: await this.settings.getBoolean('LDAP_SKIP_TLS_VERIFY', false), + domain, + baseDn: await this.settings.getValue('LDAP_BASE_DN', ''), + bindDn, + bindPassword: await this.settings.getValue('LDAP_BIND_PASSWORD', ''), + userFilter: normalizeLdapUserFilter( + await this.settings.getValue( + 'LDAP_USER_FILTER', + '(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))' + ) + ), + usernameAttr: await this.settings.getValue('LDAP_USERNAME_ATTR', 'sAMAccountName'), + emailAttr: await this.settings.getValue('LDAP_EMAIL_ATTR', 'mail'), + displayNameAttr: await this.settings.getValue('LDAP_DISPLAY_NAME_ATTR', 'displayName') + }; + } + + private async findOrCreateLdapUser(ldapResult: { dn: string; username: string; email?: string; displayName: string }) { + const linked = await this.prisma.linkedAccount.findUnique({ + where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } }, + include: { user: { include: { pinCode: true } } } + }); + if (linked?.user && !linked.user.deletedAt && linked.user.status === UserStatus.ACTIVE) { + return linked.user; + } + + if (ldapResult.email) { + const byEmail = await this.prisma.user.findFirst({ + where: { email: ldapResult.email, deletedAt: null, status: UserStatus.ACTIVE }, + include: { pinCode: true } + }); + if (byEmail) { + await this.prisma.linkedAccount.upsert({ + where: { providerName_providerId: { providerName: 'ldap', providerId: ldapResult.dn } }, + create: { + userId: byEmail.id, + providerName: 'ldap', + providerId: ldapResult.dn, + email: ldapResult.email, + displayName: ldapResult.displayName + }, + update: { email: ldapResult.email, displayName: ldapResult.displayName } + }); + return byEmail; + } + } + + const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); + if (!lockAcquired) { + throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); + } + + try { + return await this.prisma.$transaction( + async (tx) => { + const usersCount = await tx.user.count({ where: { deletedAt: null } }); + return tx.user.create({ + data: { + email: ldapResult.email ?? undefined, + username: ldapResult.username, + displayName: ldapResult.displayName, + passwordHash: null, + isSuperAdmin: usersCount === 0, + linkedAccounts: { + create: { + providerName: 'ldap', + providerId: ldapResult.dn, + email: ldapResult.email, + displayName: ldapResult.displayName + } + } + }, + include: { pinCode: true } + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + } finally { + await this.redis.releaseLock(FIRST_ADMIN_LOCK); + } + } + + private async upsertDevice(userId: string, command: Pick) { + return this.prisma.device.upsert({ + where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } }, + create: { + userId, + fingerprint: command.fingerprint, + name: command.deviceName ?? 'Новое устройство', + type: this.toDeviceType(command.deviceType), + lastIp: command.ipAddress + }, + update: { + lastSeenAt: new Date(), + lastIp: command.ipAddress, + name: command.deviceName ?? undefined, + type: this.toDeviceType(command.deviceType) + } + }); + } + + private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick): Promise { + const pinVerified = !user.pinCode?.isEnabled; + const refreshToken = randomBytes(48).toString('base64url'); + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + const session = await this.prisma.session.create({ + data: { + userId: user.id, + deviceId, + refreshHash: await bcrypt.hash(refreshToken, 12), + pinVerified, + status: pinVerified ? SessionStatus.ACTIVE : SessionStatus.LOCKED, + ipAddress: command.ipAddress, + userAgent: command.userAgent, + expiresAt + } + }); + + return { + accessToken: await this.issueAccessToken(user, session.id, pinVerified), + refreshToken, + expiresAt: expiresAt.toISOString(), + pinVerified, + user: await this.toPublicUser(user), + sessionId: session.id + }; + } + + private async issueTempPasswordToken(userId: string) { + return this.jwt.signAsync( + { sub: userId, typ: 'password_challenge' }, + { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '5m', issuer: 'id.lendry.ru' } + ); + } + + private async findUserByRecipient(recipient: string) { + return this.prisma.user.findFirst({ + where: recipient.includes('@') + ? { email: recipient, deletedAt: null, status: UserStatus.ACTIVE } + : { phone: recipient, deletedAt: null, status: UserStatus.ACTIVE }, + include: { pinCode: true } + }); + } + + private async findUserByTempPasswordToken(tempAuthToken: string) { + const payload = await this.jwt.verifyAsync<{ sub: string; typ: string }>(tempAuthToken, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + issuer: 'id.lendry.ru' + }); + if (payload.typ !== 'password_challenge') throw new UnauthorizedException('Временный токен недействителен'); + const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { pinCode: true } }); + if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('Пользователь не найден или заблокирован'); + } + return user; + } + + private async findOrCreatePasswordlessUser(recipient: string) { + const existingUser = await this.findUserByRecipient(recipient); + if (existingUser) return existingUser; + + const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); + if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); + try { + return this.prisma.$transaction( + async (tx) => { + const usersCount = await tx.user.count({ where: { deletedAt: null } }); + return tx.user.create({ + data: { + email: recipient.includes('@') ? recipient : undefined, + phone: recipient.includes('@') ? undefined : recipient, + passwordHash: null, + displayName: recipient, + isSuperAdmin: usersCount === 0 + } + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + } finally { + await this.redis.releaseLock(FIRST_ADMIN_LOCK); + } + } + + async getMe(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId } + }); + + if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('Пользователь не найден или заблокирован'); + } + + return this.toPublicUser(user); + } + + async toPublicUser(user: Pick): Promise { + const access = await this.access.getUserAccess(user.id, user.isSuperAdmin); + return { + id: user.id, + email: user.email, + phone: user.phone, + backupEmail: user.backupEmail, + backupPhone: user.backupPhone, + displayName: user.displayName, + username: user.username, + avatarUrl: user.avatarUrl, + hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl), + isSuperAdmin: user.isSuperAdmin, + status: user.status, + roles: access.roles, + permissions: access.permissions, + canAccessAdmin: access.canAccessAdmin, + canManageRoles: access.canManageRoles, + canManageOAuth: access.canManageOAuth, + canManageUsers: access.canManageUsers, + canManageSettings: access.canManageSettings, + canViewUsers: access.canViewUsers, + canViewUserDocuments: access.canViewUserDocuments + }; + } + + async issueAccessToken(user: Pick, sessionId: string, pinVerified: boolean) { + return this.jwt.signAsync( + { + sub: user.id, + sessionId, + pinVerified, + isSuperAdmin: user.isSuperAdmin + }, + { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: '15m', + issuer: 'id.lendry.ru' + } + ); + } + + private async writeSignInEvent(userId: string, success: boolean, command: LoginCommand, deviceId?: string, reason?: string) { + await this.prisma.signInEvent.create({ + data: { + userId, + deviceId, + success, + reason, + ipAddress: command.ipAddress, + userAgent: command.userAgent + } + }); + } + + private toDeviceType(value?: string): DeviceType { + if (!value) return DeviceType.WEB; + const normalized = value.toUpperCase(); + return Object.values(DeviceType).includes(normalized as DeviceType) ? (normalized as DeviceType) : DeviceType.OTHER; + } + + private isUniqueError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; + } +} diff --git a/apps/sso-core/src/domain/chat.service.ts b/apps/sso-core/src/domain/chat.service.ts new file mode 100644 index 0000000..b19c651 --- /dev/null +++ b/apps/sso-core/src/domain/chat.service.ts @@ -0,0 +1,457 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; +import { FamilyService } from './family.service'; +import { NotificationsService } from './notifications.service'; + +interface PollPayload { + question: string; + options: string[]; + allowsMultiple?: boolean; + isAnonymous?: boolean; +} + +@Injectable() +export class ChatService { + constructor( + private readonly prisma: PrismaService, + private readonly family: FamilyService, + private readonly notifications: NotificationsService + ) {} + + async listRooms(userId: string, groupId: string) { + await this.family.assertFamilyMember(groupId, userId); + let generalRoom = await this.prisma.chatRoom.findFirst({ where: { groupId, type: 'GENERAL' } }); + if (!generalRoom) { + const members = await this.prisma.familyMember.findMany({ where: { groupId } }); + generalRoom = await this.prisma.chatRoom.create({ + data: { + groupId, + type: 'GENERAL', + name: 'Общий чат', + members: { create: members.map((member) => ({ userId: member.userId })) } + } + }); + } + const rooms = await this.prisma.chatRoom.findMany({ + where: { + groupId, + members: { some: { userId } } + }, + include: { + members: { include: { user: true } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + include: { + sender: true, + poll: { include: { options: { include: { votes: true } } } } + } + } + }, + orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }] + }); + + return { + rooms: await Promise.all( + rooms.map(async (room) => { + const membership = room.members.find((member) => member.userId === userId); + const lastMessage = room.messages[0]; + return { + id: room.id, + groupId: room.groupId, + type: room.type, + name: room.name, + hasAvatar: room.hasAvatar, + updatedAt: room.updatedAt.toISOString(), + members: room.members.map((member) => ({ + id: member.id, + userId: member.userId, + displayName: member.user.displayName, + hasAvatar: Boolean(member.user.avatarStorageKey), + notificationsMuted: member.notificationsMuted + })), + lastMessage: lastMessage ? await this.toMessage(lastMessage, userId) : undefined + }; + }) + ) + }; + } + + async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) { + const group = await this.family.assertFamilyMember(groupId, userId); + const trimmedName = name.trim(); + if (!trimmedName) { + throw new BadRequestException('Укажите название группового чата'); + } + + const uniqueMembers = Array.from(new Set([userId, ...memberUserIds])); + for (const memberId of uniqueMembers) { + if (!group.members.some((member) => member.userId === memberId)) { + throw new BadRequestException('Все участники чата должны состоять в семье'); + } + } + + const room = await this.prisma.chatRoom.create({ + data: { + groupId, + type: 'GROUP', + name: trimmedName, + createdById: userId, + members: { + create: uniqueMembers.map((memberId) => ({ userId: memberId })) + } + }, + include: { + members: { include: { user: true } }, + messages: true + } + }); + + return { + id: room.id, + groupId: room.groupId, + type: room.type, + name: room.name, + hasAvatar: room.hasAvatar, + updatedAt: room.updatedAt.toISOString(), + members: room.members.map((member) => ({ + id: member.id, + userId: member.userId, + displayName: member.user.displayName, + hasAvatar: Boolean(member.user.avatarStorageKey), + notificationsMuted: member.notificationsMuted + })) + }; + } + + async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean) { + const room = await this.getRoomForMember(roomId, userId); + if (name !== undefined) { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new BadRequestException('Укажите название чата'); + } + if (room.type === 'GENERAL') { + throw new BadRequestException('Нельзя переименовать общий чат'); + } + await this.prisma.chatRoom.update({ where: { id: roomId }, data: { name: trimmedName } }); + } + if (notificationsMuted !== undefined) { + await this.prisma.chatRoomMember.update({ + where: { roomId_userId: { roomId, userId } }, + data: { notificationsMuted } + }); + } + return this.getRoomResponse(roomId, userId); + } + + async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) { + await this.getRoomForMember(roomId, userId); + const take = Math.min(Math.max(limit, 1), 100); + + let cursorDate: Date | undefined; + if (beforeMessageId) { + const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } }); + if (cursor) cursorDate = cursor.createdAt; + } + + const messages = await this.prisma.chatMessage.findMany({ + where: { + roomId, + ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}) + }, + include: { + sender: true, + poll: { include: { options: { include: { votes: true } } } } + }, + orderBy: { createdAt: 'desc' }, + take + }); + + const ordered = messages.reverse(); + return { + messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId))) + }; + } + + async sendMessage( + userId: string, + roomId: string, + type: string, + content?: string, + replyToId?: string, + storageKey?: string, + mimeType?: string, + metadataJson?: string, + poll?: PollPayload + ) { + const room = await this.getRoomForMember(roomId, userId); + const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']); + if (!allowedTypes.has(type)) { + throw new BadRequestException('Неподдерживаемый тип сообщения'); + } + + if (type === 'POLL') { + if (!poll?.question?.trim() || !poll.options?.length) { + throw new BadRequestException('Укажите вопрос и варианты опроса'); + } + if (poll.options.length < 2) { + throw new BadRequestException('В опросе должно быть минимум 2 варианта'); + } + } else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'FILE') { + if (!content?.trim()) { + throw new BadRequestException('Сообщение не может быть пустым'); + } + } + + if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'FILE') && !storageKey) { + throw new BadRequestException('Не указан файл для отправки'); + } + + if (storageKey && !storageKey.startsWith(`chat/${roomId}/`)) { + throw new BadRequestException('Некорректный ключ медиафайла'); + } + + const metadata = metadataJson ? JSON.parse(metadataJson) : undefined; + + const message = await this.prisma.$transaction(async (tx) => { + const created = await tx.chatMessage.create({ + data: { + roomId, + senderId: userId, + type, + content: content?.trim() || null, + replyToId: replyToId || null, + storageKey: storageKey || null, + mimeType: mimeType || null, + metadata: metadata ?? undefined + }, + include: { sender: true } + }); + + if (type === 'POLL' && poll) { + await tx.chatPoll.create({ + data: { + messageId: created.id, + question: poll.question.trim(), + allowsMultiple: poll.allowsMultiple ?? false, + isAnonymous: poll.isAnonymous ?? false, + options: { + create: poll.options.map((text) => ({ text: text.trim() })).filter((item) => item.text) + } + } + }); + } + + await tx.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } }); + return created; + }); + + const fullMessage = await this.prisma.chatMessage.findUnique({ + where: { id: message.id }, + include: { + sender: true, + poll: { include: { options: { include: { votes: true } } } } + } + }); + + const response = await this.toMessage(fullMessage!, userId); + await this.notifyRoomMembers(room, userId, response); + return response; + } + + async votePoll(userId: string, messageId: string, optionIds: string[]) { + const message = await this.prisma.chatMessage.findUnique({ + where: { id: messageId }, + include: { + poll: { include: { options: true } }, + room: { include: { members: true, group: true } } + } + }); + if (!message?.poll) { + throw new NotFoundException('Опрос не найден'); + } + await this.getRoomForMember(message.roomId, userId); + + const validOptionIds = new Set(message.poll.options.map((option) => option.id)); + for (const optionId of optionIds) { + if (!validOptionIds.has(optionId)) { + throw new BadRequestException('Некорректный вариант опроса'); + } + } + if (!message.poll.allowsMultiple && optionIds.length > 1) { + throw new BadRequestException('Можно выбрать только один вариант'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.chatPollVote.deleteMany({ + where: { + userId, + option: { pollId: message.poll!.id } + } + }); + for (const optionId of optionIds) { + await tx.chatPollVote.create({ data: { optionId, userId } }); + } + }); + + const updated = await this.prisma.chatMessage.findUnique({ + where: { id: messageId }, + include: { + sender: true, + poll: { include: { options: { include: { votes: true } } } } + } + }); + return this.toMessage(updated!, userId); + } + + async setRoomNotificationsMuted(userId: string, roomId: string, muted: boolean) { + await this.getRoomForMember(roomId, userId); + await this.prisma.chatRoomMember.update({ + where: { roomId_userId: { roomId, userId } }, + data: { notificationsMuted: muted } + }); + return { count: 1 }; + } + + async assertRoomMember(roomId: string, userId: string) { + return this.getRoomForMember(roomId, userId); + } + + private async getRoomForMember(roomId: string, userId: string) { + const room = await this.prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: true, + group: { include: { members: true } } + } + }); + if (!room) { + throw new NotFoundException('Чат не найден'); + } + if (!room.members.some((member) => member.userId === userId)) { + throw new ForbiddenException('Вы не состоите в этом чате'); + } + return room; + } + + private async getRoomResponse(roomId: string, userId: string) { + const room = await this.getRoomForMember(roomId, userId); + const full = await this.prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { members: { include: { user: true } } } + }); + return { + id: full!.id, + groupId: full!.groupId, + type: full!.type, + name: full!.name, + hasAvatar: full!.hasAvatar, + updatedAt: full!.updatedAt.toISOString(), + members: full!.members.map((member) => ({ + id: member.id, + userId: member.userId, + displayName: member.user.displayName, + hasAvatar: Boolean(member.user.avatarStorageKey), + notificationsMuted: member.notificationsMuted + })) + }; + } + + private async notifyRoomMembers( + room: { id: string; name: string; members: Array<{ userId: string; notificationsMuted: boolean }> }, + senderId: string, + message: Awaited> + ) { + const sender = await this.prisma.user.findUnique({ where: { id: senderId } }); + const preview = + message.type === 'TEXT' || message.type === 'EMOJI' + ? (message.content ?? '') + : message.type === 'POLL' + ? '📊 Опрос' + : message.type === 'VOICE' + ? '🎤 Голосовое' + : message.type === 'AUDIO' + ? '🎵 Аудио' + : message.type === 'FILE' + ? '📄 Файл' + : message.type === 'IMAGE' + ? '📷 Фото' + : '📎 Медиа'; + + for (const member of room.members) { + if (member.userId === senderId) continue; + await this.notifications.publishRealtime(member.userId, 'chat_message', room.name, preview.slice(0, 120), { + roomId: room.id, + message + }); + } + + for (const member of room.members) { + if (member.userId === senderId || member.notificationsMuted) continue; + await this.notifications.create( + member.userId, + 'chat_message', + room.name, + `${sender?.displayName ?? 'Участник'}: ${preview.slice(0, 120)}`, + { roomId: room.id, messageId: message.id }, + { skipPublish: true } + ); + } + } + + private async toMessage( + message: { + id: string; + roomId: string; + senderId: string; + type: string; + content: string | null; + replyToId: string | null; + storageKey: string | null; + mimeType: string | null; + metadata: unknown; + createdAt: Date; + sender: { displayName: string; avatarStorageKey: string | null }; + poll?: { + id: string; + question: string; + allowsMultiple: boolean; + isAnonymous: boolean; + closedAt: Date | null; + options: Array<{ id: string; text: string; votes: Array<{ userId: string }> }>; + } | null; + }, + viewerId: string + ) { + return { + id: message.id, + roomId: message.roomId, + senderId: message.senderId, + senderName: message.sender.displayName, + senderHasAvatar: Boolean(message.sender.avatarStorageKey), + type: message.type, + content: message.content ?? undefined, + replyToId: message.replyToId ?? undefined, + storageKey: message.storageKey ?? undefined, + mimeType: message.mimeType ?? undefined, + metadataJson: message.metadata ? JSON.stringify(message.metadata) : undefined, + createdAt: message.createdAt.toISOString(), + poll: message.poll + ? { + id: message.poll.id, + question: message.poll.question, + allowsMultiple: message.poll.allowsMultiple, + isAnonymous: message.poll.isAnonymous, + closedAt: message.poll.closedAt?.toISOString(), + options: message.poll.options.map((option) => ({ + id: option.id, + text: option.text, + voteCount: option.votes.length, + votedByMe: option.votes.some((vote) => vote.userId === viewerId) + })) + } + : undefined + }; + } +} diff --git a/apps/sso-core/src/domain/documents.service.ts b/apps/sso-core/src/domain/documents.service.ts new file mode 100644 index 0000000..e3e2183 --- /dev/null +++ b/apps/sso-core/src/domain/documents.service.ts @@ -0,0 +1,140 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '../generated/prisma/client'; +import { EncryptionService } from '../infra/encryption.service'; +import { PrismaService } from '../infra/prisma.service'; + +export const DOCUMENT_TYPES = [ + 'PASSPORT_RF', + 'FOREIGN_PASSPORT', + 'BIRTH_CERTIFICATE', + 'DRIVER_LICENSE', + 'VEHICLE_REGISTRATION', + 'OMS', + 'DMS', + 'INN', + 'SNILS' +] as const; + +export type DocumentType = (typeof DOCUMENT_TYPES)[number]; + +interface CreateDocumentCommand { + userId: string; + type: string; + number: string; + issuedAt?: string; + expiresAt?: string; + metadataJson?: string; +} + +interface UpdateDocumentCommand { + documentId: string; + userId: string; + number?: string; + issuedAt?: string; + expiresAt?: string; + metadataJson?: string; +} + +@Injectable() +export class DocumentsService { + constructor( + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService + ) {} + + private assertType(type: string): asserts type is DocumentType { + if (!DOCUMENT_TYPES.includes(type as DocumentType)) { + throw new BadRequestException('Некорректный тип документа'); + } + } + + async create(command: CreateDocumentCommand) { + this.assertType(command.type); + + const metadata = command.metadataJson + ? ({ encryptedPayload: this.encryption.encrypt(command.metadataJson) } satisfies Prisma.InputJsonObject) + : undefined; + + const document = await this.prisma.userDocument.create({ + data: { + userId: command.userId, + type: command.type, + number: this.encryption.encrypt(command.number || '—'), + issuedAt: command.issuedAt ? new Date(command.issuedAt) : undefined, + expiresAt: command.expiresAt ? new Date(command.expiresAt) : undefined, + metadata + } + }); + + return this.toResponse(document); + } + + async list(userId: string) { + const documents = await this.prisma.userDocument.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' } + }); + return { documents: documents.map((document) => this.toResponse(document)) }; + } + + async get(documentId: string, userId: string) { + const document = await this.prisma.userDocument.findFirst({ + where: { id: documentId, userId } + }); + if (!document) { + throw new NotFoundException('Документ не найден'); + } + return this.toResponse(document); + } + + async update(command: UpdateDocumentCommand) { + const existing = await this.prisma.userDocument.findFirst({ + where: { id: command.documentId, userId: command.userId } + }); + if (!existing) { + throw new NotFoundException('Документ не найден'); + } + + const metadata = command.metadataJson + ? ({ encryptedPayload: this.encryption.encrypt(command.metadataJson) } satisfies Prisma.InputJsonObject) + : undefined; + + const document = await this.prisma.userDocument.update({ + where: { id: command.documentId }, + data: { + number: command.number ? this.encryption.encrypt(command.number) : undefined, + issuedAt: command.issuedAt ? new Date(command.issuedAt) : undefined, + expiresAt: command.expiresAt ? new Date(command.expiresAt) : undefined, + metadata + } + }); + + return this.toResponse(document); + } + + async delete(documentId: string, userId: string) { + const existing = await this.prisma.userDocument.findFirst({ + where: { id: documentId, userId } + }); + if (!existing) { + throw new NotFoundException('Документ не найден'); + } + await this.prisma.userDocument.delete({ where: { id: documentId } }); + return { count: 1 }; + } + + private toResponse(document: Awaited>) { + const metadata = document.metadata as { encryptedPayload?: string } | null; + return { + id: document.id, + userId: document.userId, + type: document.type, + number: this.encryption.decrypt(document.number), + issuedAt: document.issuedAt?.toISOString(), + expiresAt: document.expiresAt?.toISOString(), + metadataJson: metadata?.encryptedPayload ? this.encryption.decrypt(metadata.encryptedPayload) : undefined, + createdAt: document.createdAt.toISOString(), + updatedAt: document.updatedAt.toISOString() + }; + } +} diff --git a/apps/sso-core/src/domain/dto.ts b/apps/sso-core/src/domain/dto.ts new file mode 100644 index 0000000..5d99b92 --- /dev/null +++ b/apps/sso-core/src/domain/dto.ts @@ -0,0 +1,61 @@ +export interface RegisterCommand { + email?: string; + phone?: string; + password?: string; + displayName: string; + username?: string; +} + +export interface LoginCommand { + login: string; + password: string; + deviceName?: string; + deviceType?: string; + fingerprint: string; + ipAddress?: string; + userAgent?: string; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: string; + pinVerified: boolean; + sessionId: string; + user: PublicUser; +} + +export interface PublicUser { + id: string; + email?: string | null; + phone?: string | null; + backupEmail?: string | null; + backupPhone?: string | null; + displayName: string; + username?: string | null; + avatarUrl?: string | null; + hasAvatar?: boolean; + isSuperAdmin: boolean; + status: string; + roles?: string[]; + permissions?: string[]; + canAccessAdmin?: boolean; + canManageRoles?: boolean; + canManageOAuth?: boolean; + canManageUsers?: boolean; + canManageSettings?: boolean; + canViewUsers?: boolean; + canViewUserDocuments?: boolean; +} + +export interface VerifyPinCommand { + sessionId: string; + pin: string; +} + +export interface UpsertSettingCommand { + key: string; + value: string; + description?: string; + isSecret?: boolean; +} diff --git a/apps/sso-core/src/domain/family.service.ts b/apps/sso-core/src/domain/family.service.ts new file mode 100644 index 0000000..2c15a13 --- /dev/null +++ b/apps/sso-core/src/domain/family.service.ts @@ -0,0 +1,786 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; + +import { PrismaService } from '../infra/prisma.service'; + +import { SettingsService } from './settings.service'; + +import { NotificationsService } from './notifications.service'; + + + +const INVITE_TTL_DAYS = 7; + + + +@Injectable() + +export class FamilyService { + + constructor( + + private readonly prisma: PrismaService, + + private readonly settings: SettingsService, + + private readonly notifications: NotificationsService + + ) {} + + + + async create(ownerId: string, name: string) { + + const trimmedName = name.trim(); + + if (!trimmedName) { + + throw new BadRequestException('Укажите название семьи'); + + } + + + + const group = await this.prisma.$transaction(async (tx) => { + + const created = await tx.familyGroup.create({ + + data: { + + ownerId, + + name: trimmedName, + + members: { create: { userId: ownerId, role: 'owner' } } + + }, + + include: { members: { include: { user: true } } } + + }); + + + + const generalRoom = await tx.chatRoom.create({ + + data: { + + groupId: created.id, + + type: 'GENERAL', + + name: 'Общий чат', + + createdById: ownerId, + + members: { create: { userId: ownerId } } + + } + + }); + + + + return { ...created, generalRoomId: generalRoom.id }; + + }); + + + + return this.toGroup(group); + + } + + + + async list(userId: string) { + + const groups = await this.prisma.familyGroup.findMany({ + + where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] }, + + include: { members: { include: { user: true } } }, + + orderBy: { updatedAt: 'desc' } + + }); + + return { groups: groups.map((group) => this.toGroup(group)) }; + + } + + + + async get(requesterId: string, groupId: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertMember(group, requesterId); + + return this.toGroup(group); + + } + + + + async update(requesterId: string, groupId: string, name: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertOwner(group, requesterId); + + const trimmedName = name.trim(); + + if (!trimmedName) { + + throw new BadRequestException('Укажите название семьи'); + + } + + const updated = await this.prisma.familyGroup.update({ + + where: { id: groupId }, + + data: { name: trimmedName }, + + include: { members: { include: { user: true } } } + + }); + + return this.toGroup(updated); + + } + + + + async delete(requesterId: string, groupId: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertOwner(group, requesterId); + + await this.prisma.familyGroup.delete({ where: { id: groupId } }); + + return { count: 1 }; + + } + + + + async addMember(groupId: string, userId: string, role: string) { + + const group = await this.prisma.familyGroup.findUnique({ + + where: { id: groupId }, + + include: { members: true } + + }); + + if (!group) { + + throw new BadRequestException('Семейная группа не найдена'); + + } + + const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6); + + if (group.members.length >= maxMembers) { + + throw new BadRequestException(`В группе может быть не более ${maxMembers} участников`); + + } + + + + const member = await this.prisma.$transaction(async (tx) => { + + const created = await tx.familyMember.create({ data: { groupId, userId, role } }); + + const rooms = await tx.chatRoom.findMany({ where: { groupId } }); + + for (const room of rooms) { + + if (room.type === 'GENERAL') { + + await tx.chatRoomMember.upsert({ + + where: { roomId_userId: { roomId: room.id, userId } }, + + create: { roomId: room.id, userId }, + + update: {} + + }); + + } + + } + + return created; + + }); + + + + return this.toMember(member); + + } + + + + async removeMember(requesterId: string, memberId: string) { + + const member = await this.prisma.familyMember.findUnique({ + + where: { id: memberId }, + + include: { group: { include: { members: true } } } + + }); + + if (!member) { + + throw new NotFoundException('Участник не найден'); + + } + + const isSelf = member.userId === requesterId; + + const isOwner = member.group.ownerId === requesterId; + + if (!isSelf && !isOwner) { + + throw new ForbiddenException('Недостаточно прав для удаления участника'); + + } + + if (member.role === 'owner') { + + throw new BadRequestException('Нельзя удалить владельца семьи'); + + } + + + + await this.prisma.$transaction(async (tx) => { + + await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: member.groupId } } }); + + await tx.familyMember.delete({ where: { id: memberId } }); + + }); + + return { count: 1 }; + + } + + + + async sendInvite(requesterId: string, groupId: string, target: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertMember(group, requesterId); + + + + const trimmedTarget = target.trim(); + + if (!trimmedTarget) { + + throw new BadRequestException('Укажите email, телефон или логин приглашаемого'); + + } + + + + const invitee = await this.findUserByContact(trimmedTarget); + + if (invitee?.id === requesterId) { + + throw new BadRequestException('Нельзя пригласить самого себя'); + + } + + if (invitee && group.members.some((member) => member.userId === invitee.id)) { + + throw new BadRequestException('Пользователь уже состоит в семье'); + + } + + + + const existingPending = invitee + + ? await this.prisma.familyInvite.findFirst({ + + where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' } + + }) + + : null; + + if (existingPending) { + + throw new BadRequestException('Приглашение уже отправлено'); + + } + + + + const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } }); + + const invite = await this.prisma.familyInvite.create({ + + data: { + + groupId, + + inviterId: requesterId, + + inviteeUserId: invitee?.id, + + targetEmail: trimmedTarget.includes('@') ? trimmedTarget : undefined, + + targetPhone: !trimmedTarget.includes('@') && /^\+?\d/.test(trimmedTarget) ? trimmedTarget : undefined, + + expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000) + + }, + + include: { + + group: true, + + inviter: true + + } + + }); + + + + if (invitee) { + + await this.notifications.create( + + invitee.id, + + 'family_invite', + + 'Приглашение в семью', + + `${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`, + + { inviteId: invite.id, groupId, groupName: group.name } + + ); + + } + + + + return this.toInvite(invite); + + } + + + + async respondInvite(userId: string, inviteId: string, accept: boolean) { + + const invite = await this.prisma.familyInvite.findUnique({ + + where: { id: inviteId }, + + include: { group: { include: { members: true } }, inviter: true } + + }); + + if (!invite) { + + throw new NotFoundException('Приглашение не найдено'); + + } + + if (invite.status !== 'PENDING') { + + throw new BadRequestException('Приглашение уже обработано'); + + } + + if (invite.expiresAt.getTime() < Date.now()) { + + await this.prisma.familyInvite.update({ where: { id: inviteId }, data: { status: 'EXPIRED' } }); + + throw new BadRequestException('Срок действия приглашения истёк'); + + } + + if (invite.inviteeUserId && invite.inviteeUserId !== userId) { + + throw new ForbiddenException('Это приглашение предназначено другому пользователю'); + + } + + + + if (!accept) { + + const declined = await this.prisma.familyInvite.update({ + + where: { id: inviteId }, + + data: { status: 'DECLINED', inviteeUserId: invite.inviteeUserId ?? userId }, + + include: { group: true, inviter: true } + + }); + + await this.notifications.dismissFamilyInvite(userId, inviteId); + + return this.toInvite(declined); + + } + + + + const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6); + + if (invite.group.members.length >= maxMembers) { + + throw new BadRequestException(`В семье уже максимум ${maxMembers} участников`); + + } + + + + await this.prisma.$transaction(async (tx) => { + + await tx.familyInvite.update({ + + where: { id: inviteId }, + + data: { status: 'ACCEPTED', inviteeUserId: userId } + + }); + + await tx.familyMember.create({ + + data: { groupId: invite.groupId, userId, role: 'member' } + + }); + + const rooms = await tx.chatRoom.findMany({ where: { groupId: invite.groupId } }); + + for (const room of rooms) { + + if (room.type === 'GENERAL') { + + await tx.chatRoomMember.upsert({ + + where: { roomId_userId: { roomId: room.id, userId } }, + + create: { roomId: room.id, userId }, + + update: {} + + }); + + } + + } + + }); + + + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + + await this.notifications.create( + + invite.inviterId, + + 'family_invite_accepted', + + 'Приглашение принято', + + `${user?.displayName ?? 'Пользователь'} присоединился к «${invite.group.name}»`, + + { groupId: invite.groupId, userId } + + ); + + + + const updated = await this.prisma.familyInvite.findUnique({ + + where: { id: inviteId }, + + include: { group: true, inviter: true } + + }); + + await this.notifications.dismissFamilyInvite(userId, inviteId); + + return this.toInvite(updated!); + + } + + + + async listInvites(userId: string, groupId?: string) { + + const invites = await this.prisma.familyInvite.findMany({ + + where: { + + ...(groupId ? { groupId } : {}), + inviteeUserId: userId, + status: 'PENDING' + }, + + include: { group: true, inviter: true }, + + orderBy: { createdAt: 'desc' } + + }); + + return { invites: invites.map((invite) => this.toInvite(invite)) }; + + } + + + + async assertFamilyMember(groupId: string, userId: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertMember(group, userId); + + return group; + + } + + + + private async getGroupWithMembers(groupId: string) { + + const group = await this.prisma.familyGroup.findUnique({ + + where: { id: groupId }, + + include: { members: { include: { user: true } } } + + }); + + if (!group) { + + throw new NotFoundException('Семейная группа не найдена'); + + } + + return group; + + } + + + + private assertMember(group: { members: Array<{ userId: string }> }, userId: string) { + + if (!group.members.some((member) => member.userId === userId)) { + + throw new ForbiddenException('Вы не состоите в этой семье'); + + } + + } + + + + private assertOwner(group: { ownerId: string }, userId: string) { + + if (group.ownerId !== userId) { + + throw new ForbiddenException('Только владелец семьи может выполнить это действие'); + + } + + } + + + + private async findUserByContact(target: string) { + + if (target.includes('@')) { + + return this.prisma.user.findFirst({ where: { email: target, status: 'ACTIVE' } }); + + } + + const normalizedPhone = target.replace(/\D/g, ''); + + return this.prisma.user.findFirst({ + + where: { + + status: 'ACTIVE', + + OR: [{ phone: target }, { phone: { contains: normalizedPhone.slice(-10) } }, { username: target }] + + } + + }); + + } + + + + private toGroup(group: { + + id: string; + + ownerId: string; + + name: string; + + hasAvatar: boolean; + + createdAt: Date; + + updatedAt: Date; + + members: Array<{ + + id: string; + + groupId: string; + + userId: string; + + role: string; + + createdAt: Date; + + user?: { displayName: string; avatarStorageKey: string | null }; + + }>; + + }) { + + return { + + id: group.id, + + ownerId: group.ownerId, + + name: group.name, + + hasAvatar: group.hasAvatar, + + createdAt: group.createdAt.toISOString(), + + updatedAt: group.updatedAt.toISOString(), + + members: group.members.map((member) => this.toMember(member)) + + }; + + } + + + + private toMember(member: { + + id: string; + + groupId: string; + + userId: string; + + role: string; + + createdAt: Date; + + user?: { displayName: string; avatarStorageKey: string | null }; + + }) { + + return { + + id: member.id, + + groupId: member.groupId, + + userId: member.userId, + + role: member.role, + + displayName: member.user?.displayName ?? 'Участник', + + hasAvatar: Boolean(member.user?.avatarStorageKey), + + createdAt: member.createdAt.toISOString() + + }; + + } + + + + private toInvite(invite: { + + id: string; + + groupId: string; + + inviterId: string; + + inviteeUserId: string | null; + + targetEmail: string | null; + + targetPhone: string | null; + + status: string; + + expiresAt: Date; + + createdAt: Date; + + group: { name: string }; + + inviter: { displayName: string }; + + }) { + + return { + + id: invite.id, + + groupId: invite.groupId, + + groupName: invite.group.name, + + inviterId: invite.inviterId, + + inviterName: invite.inviter.displayName, + + inviteeUserId: invite.inviteeUserId ?? undefined, + + targetEmail: invite.targetEmail ?? undefined, + + targetPhone: invite.targetPhone ?? undefined, + + status: invite.status, + + expiresAt: invite.expiresAt.toISOString(), + + createdAt: invite.createdAt.toISOString() + + }; + + } + +} + + diff --git a/apps/sso-core/src/domain/media.service.ts b/apps/sso-core/src/domain/media.service.ts new file mode 100644 index 0000000..e6b354e --- /dev/null +++ b/apps/sso-core/src/domain/media.service.ts @@ -0,0 +1,283 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { PrismaService } from '../infra/prisma.service'; +import { AccessService } from './access.service'; +import { MinioService } from '../infra/minio.service'; + +const AVATAR_ACCESS_TTL_SECONDS = 900; + +interface MediaStreamPayload { + purpose: 'media-stream'; + objectKey: string; + ownerId: string; + roomId?: string; + fileName?: string; +} + +@Injectable() +export class MediaService { + constructor( + private readonly prisma: PrismaService, + private readonly minio: MinioService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly access: AccessService + ) {} + + async createAvatarUploadUrl(userId: string, contentType: string) { + try { + this.minio.assertImageContentType(contentType); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); + } + const extension = this.minio.extensionForContentType(contentType); + const storageKey = `avatars/${userId}/${crypto.randomUUID()}.${extension}`; + const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType); + return { ...presigned, storageKey }; + } + + async confirmAvatar(userId: string, storageKey: string) { + if (!storageKey.startsWith(`avatars/${userId}/`)) { + throw new BadRequestException('Некорректный ключ аватара'); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { + avatarStorageKey: storageKey, + avatarUrl: null + } + }); + + return { userId, hasAvatar: true }; + } + + async getAvatarAccessUrl(requesterId: string, targetUserId: string) { + const user = await this.prisma.user.findUnique({ where: { id: targetUserId } }); + if (!user?.avatarStorageKey) { + throw new NotFoundException('Аватар не найден'); + } + + if (requesterId !== targetUserId) { + const requester = await this.prisma.user.findUnique({ where: { id: requesterId } }); + if (!requester?.isSuperAdmin) { + throw new ForbiddenException('Нет доступа к аватару пользователя'); + } + } + + const token = await this.jwt.signAsync( + { + purpose: 'media-stream', + objectKey: user.avatarStorageKey, + ownerId: targetUserId + } satisfies MediaStreamPayload, + { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: AVATAR_ACCESS_TTL_SECONDS, + issuer: 'id.lendry.ru' + } + ); + + const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); + return { + accessUrl: `${publicApiUrl}/media/stream/${token}`, + expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() + }; + } + + async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) { + try { + this.minio.assertImageContentType(contentType); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); + } + const document = await this.prisma.userDocument.findFirst({ + where: { id: documentId, userId } + }); + if (!document) { + throw new NotFoundException('Документ не найден'); + } + + const extension = this.minio.extensionForContentType(contentType); + const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`; + const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType); + return { ...presigned, storageKey }; + } + + async resolveStreamToken(token: string, requesterId?: string) { + let payload: MediaStreamPayload; + try { + payload = await this.jwt.verifyAsync(token, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + issuer: 'id.lendry.ru' + }); + } catch { + throw new ForbiddenException('Ссылка недействительна или истекла'); + } + + if (payload.purpose !== 'media-stream' || !payload.objectKey) { + throw new ForbiddenException('Ссылка недействительна'); + } + + if (payload.objectKey.startsWith('chat/')) { + if (!requesterId) { + throw new ForbiddenException('Для доступа к файлу чата требуется авторизация'); + } + const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey); + if (!roomId) { + throw new ForbiddenException('Некорректный ключ файла чата'); + } + await this.assertChatMember(roomId, requesterId); + } + + const contentType = this.minio.contentTypeForKey(payload.objectKey); + + return { + storageKey: payload.objectKey, + contentType, + fileName: payload.fileName + }; + } + + async getObjectStream(storageKey: string) { + return this.minio.getClient().send( + new GetObjectCommand({ + Bucket: this.minio.getBucket(), + Key: storageKey + }) + ); + } + + async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) { + await this.access.assertCanViewUserDocuments(requesterId, targetUserId); + + if (!storageKey.startsWith(`documents/${targetUserId}/`)) { + throw new BadRequestException('Некорректный ключ фото документа'); + } + + const token = await this.jwt.signAsync( + { + purpose: 'media-stream', + objectKey: storageKey, + ownerId: targetUserId + } satisfies MediaStreamPayload, + { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: AVATAR_ACCESS_TTL_SECONDS, + issuer: 'id.lendry.ru' + } + ); + + const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); + return { + accessUrl: `${publicApiUrl}/media/stream/${token}`, + expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() + }; + } + + async createFamilyAvatarUploadUrl(requesterId: string, groupId: string, contentType: string) { + await this.assertFamilyMember(groupId, requesterId); + try { + this.minio.assertImageContentType(contentType); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); + } + const extension = this.minio.extensionForContentType(contentType); + const storageKey = `families/${groupId}/${crypto.randomUUID()}.${extension}`; + const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType); + return { ...presigned, storageKey }; + } + + async confirmFamilyAvatar(requesterId: string, groupId: string, storageKey: string) { + await this.assertFamilyMember(groupId, requesterId); + if (!storageKey.startsWith(`families/${groupId}/`)) { + throw new BadRequestException('Некорректный ключ аватара семьи'); + } + await this.prisma.familyGroup.update({ + where: { id: groupId }, + data: { avatarStorageKey: storageKey, hasAvatar: true } + }); + return { groupId, hasAvatar: true }; + } + + async getFamilyAvatarAccessUrl(requesterId: string, groupId: string) { + await this.assertFamilyMember(groupId, requesterId); + const group = await this.prisma.familyGroup.findUnique({ where: { id: groupId } }); + if (!group?.avatarStorageKey) { + throw new NotFoundException('Аватар семьи не найден'); + } + return this.createStreamAccessUrl(group.avatarStorageKey, groupId); + } + + async createChatMediaUploadUrl(requesterId: string, roomId: string, contentType: string, fileName?: string) { + await this.assertChatMember(roomId, requesterId); + let normalized: string; + try { + normalized = this.minio.assertChatMediaContentType(contentType, fileName); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); + } + const extension = this.minio.extensionForChatMedia(normalized, fileName); + const storageKey = `chat/${roomId}/${crypto.randomUUID()}.${extension}`; + const presigned = await this.minio.createPresignedUploadUrl(storageKey, normalized); + return { ...presigned, storageKey }; + } + + async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) { + await this.assertChatMember(roomId, requesterId); + if (!storageKey.startsWith(`chat/${roomId}/`)) { + throw new BadRequestException('Некорректный ключ медиафайла'); + } + return this.createStreamAccessUrl(storageKey, requesterId, { roomId, fileName }); + } + + private extractChatRoomId(storageKey: string) { + const [prefix, roomId] = storageKey.split('/'); + if (prefix !== 'chat' || !roomId) { + return null; + } + return roomId; + } + + private async createStreamAccessUrl( + storageKey: string, + ownerId: string, + options?: { roomId?: string; fileName?: string } + ) { + const token = await this.jwt.signAsync( + { + purpose: 'media-stream', + objectKey: storageKey, + ownerId, + roomId: options?.roomId, + fileName: options?.fileName + } satisfies MediaStreamPayload, + { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: AVATAR_ACCESS_TTL_SECONDS, + issuer: 'id.lendry.ru' + } + ); + const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); + return { + accessUrl: `${publicApiUrl}/media/stream/${token}`, + expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() + }; + } + + private async assertFamilyMember(groupId: string, userId: string) { + const member = await this.prisma.familyMember.findFirst({ where: { groupId, userId } }); + if (!member) { + throw new ForbiddenException('Нет доступа к семье'); + } + } + + private async assertChatMember(roomId: string, userId: string) { + const member = await this.prisma.chatRoomMember.findFirst({ where: { roomId, userId } }); + if (!member) { + throw new ForbiddenException('Нет доступа к чату'); + } + } +} diff --git a/apps/sso-core/src/domain/notifications.service.ts b/apps/sso-core/src/domain/notifications.service.ts new file mode 100644 index 0000000..52d903c --- /dev/null +++ b/apps/sso-core/src/domain/notifications.service.ts @@ -0,0 +1,166 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; +import { NotificationPublisherService } from '../infra/notification-publisher.service'; + +@Injectable() +export class NotificationsService { + constructor( + private readonly prisma: PrismaService, + private readonly publisher: NotificationPublisherService + ) {} + + async create( + userId: string, + type: string, + title: string, + message: string, + payload?: Record, + options?: { skipPublish?: boolean } + ) { + const notification = await this.prisma.notification.create({ + data: { + userId, + type, + title, + message, + payload: (payload ?? undefined) as never + } + }); + + if (!options?.skipPublish) { + await this.publisher.publish({ + userId, + type, + title, + message, + payload: { + notificationId: notification.id, + ...(payload ?? {}) + } + }); + } + + return this.toResponse(notification); + } + + async publishRealtime(userId: string, type: string, title: string, message: string, payload?: Record) { + await this.publisher.publish({ userId, type, title, message, payload }); + } + + async list(userId: string, limit = 30, unreadOnly = false) { + const notifications = await this.prisma.notification.findMany({ + where: { + userId, + ...(unreadOnly ? { isRead: false } : {}) + }, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(limit, 1), 100) + }); + + const inviteIds = notifications + .filter((item) => item.type === 'family_invite') + .map((item) => (item.payload as { inviteId?: string } | null)?.inviteId) + .filter((inviteId): inviteId is string => Boolean(inviteId)); + + const pendingInviteIds = new Set(); + if (inviteIds.length) { + const invites = await this.prisma.familyInvite.findMany({ + where: { id: { in: inviteIds }, status: 'PENDING' } + }); + invites.forEach((invite) => pendingInviteIds.add(invite.id)); + } + + const staleIds: string[] = []; + const filtered = notifications.filter((item) => { + if (item.type !== 'family_invite') return true; + const inviteId = (item.payload as { inviteId?: string } | null)?.inviteId; + if (!inviteId || !pendingInviteIds.has(inviteId)) { + staleIds.push(item.id); + return false; + } + return true; + }); + + if (staleIds.length) { + await this.prisma.notification.deleteMany({ + where: { id: { in: staleIds }, userId } + }); + } + + return { notifications: filtered.map((item) => this.toResponse(item)) }; + } + + async unreadCount(userId: string) { + const count = await this.prisma.notification.count({ + where: { userId, isRead: false } + }); + return { count }; + } + + async markRead(userId: string, notificationId: string) { + return this.delete(userId, notificationId); + } + + async markAllRead(userId: string) { + return this.deleteAll(userId); + } + + async delete(userId: string, notificationId: string) { + const existing = await this.prisma.notification.findFirst({ + where: { id: notificationId, userId } + }); + if (!existing) { + throw new NotFoundException('Уведомление не найдено'); + } + await this.prisma.notification.delete({ where: { id: notificationId } }); + return { count: 1 }; + } + + async deleteAll(userId: string) { + const result = await this.prisma.notification.deleteMany({ where: { userId } }); + return { count: result.count }; + } + + async dismissFamilyInvite(userId: string, inviteId: string) { + const items = await this.prisma.notification.findMany({ + where: { userId, type: 'family_invite' } + }); + const ids = items + .filter((item) => { + const payload = item.payload as { inviteId?: string } | null; + return payload?.inviteId === inviteId; + }) + .map((item) => item.id); + + if (!ids.length) { + return { count: 0 }; + } + + await this.prisma.notification.deleteMany({ + where: { id: { in: ids }, userId } + }); + return { count: ids.length }; + } + + private toResponse(notification: { + id: string; + userId: string; + type: string; + title: string; + message: string; + payload: unknown; + isRead: boolean; + createdAt: Date; + }) { + return { + id: notification.id, + userId: notification.userId, + type: notification.type, + title: notification.title, + message: notification.message, + payloadJson: notification.payload ? JSON.stringify(notification.payload) : '', + isRead: notification.isRead, + createdAt: notification.createdAt.toISOString() + }; + } +} diff --git a/apps/sso-core/src/domain/oauth-core.service.ts b/apps/sso-core/src/domain/oauth-core.service.ts new file mode 100644 index 0000000..67f977f --- /dev/null +++ b/apps/sso-core/src/domain/oauth-core.service.ts @@ -0,0 +1,78 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import { randomBytes } from 'node:crypto'; +import { PrismaService } from '../infra/prisma.service'; + +@Injectable() +export class OAuthCoreService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService + ) {} + + async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) { + const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } }); + if (!client?.isActive || !client.redirectUris.includes(command.redirectUri)) { + throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен'); + } + + const code = randomBytes(32).toString('base64url'); + await this.prisma.oAuthAuthorizationCode.create({ + data: { + code, + userId: command.userId, + clientId: client.id, + redirectUri: command.redirectUri, + scopes: command.scope.split(' ').filter(Boolean), + expiresAt: new Date(Date.now() + 5 * 60_000) + } + }); + + const url = new URL(command.redirectUri); + url.searchParams.set('code', code); + if (command.state) url.searchParams.set('state', command.state); + return { redirectUrl: url.toString(), code, state: command.state }; + } + + async token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) { + const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } }); + if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено'); + if (client.clientSecret && client.clientSecret !== command.clientSecret) throw new UnauthorizedException('Неверный client_secret'); + + if (command.grantType === 'authorization_code') { + if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri'); + const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({ where: { code: command.code }, include: { user: true } }); + if (!authCode || authCode.consumedAt || authCode.expiresAt < new Date() || authCode.redirectUri !== command.redirectUri || authCode.clientId !== client.id) { + throw new UnauthorizedException('Код авторизации недействителен'); + } + await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } }); + return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes); + } + + if (command.grantType === 'refresh_token') { + if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token'); + const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(command.refreshToken, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET') }); + if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) throw new UnauthorizedException('Refresh token недействителен'); + return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []); + } + + throw new BadRequestException('Неподдерживаемый grant_type'); + } + + async userInfo(accessToken: string) { + const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET') }); + const user = await this.prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user) throw new UnauthorizedException('Пользователь не найден'); + return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl }; + } + + private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) { + const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' }); + const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer: 'id.lendry.ru' }); + const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' }); + return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken }; + } +} diff --git a/apps/sso-core/src/domain/otp.service.ts b/apps/sso-core/src/domain/otp.service.ts new file mode 100644 index 0000000..e9c0b5d --- /dev/null +++ b/apps/sso-core/src/domain/otp.service.ts @@ -0,0 +1,49 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { randomInt } from 'node:crypto'; +import { PrismaService } from '../infra/prisma.service'; +import { SettingsService } from './settings.service'; + +@Injectable() +export class OtpService { + constructor( + private readonly prisma: PrismaService, + private readonly settings: SettingsService + ) {} + + async send(command: { target: string; channel: string; purpose: string; userId?: string }) { + const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10); + const code = String(randomInt(100000, 999999)); + const expiresAt = new Date(Date.now() + expiryMinutes * 60_000); + await this.prisma.authCode.create({ + data: { + userId: command.userId, + target: command.target, + channel: command.channel, + purpose: command.purpose, + codeHash: await bcrypt.hash(code, 10), + expiresAt + } + }); + console.log(`OTP ${command.channel} для ${command.target}: ${code}`); + return { sent: true, expiresAt: expiresAt.toISOString() }; + } + + async verify(command: { target: string; code: string; purpose: string }) { + const authCode = await this.prisma.authCode.findFirst({ + where: { + target: command.target, + purpose: command.purpose, + usedAt: null, + expiresAt: { gt: new Date() } + }, + orderBy: { createdAt: 'desc' } + }); + + if (!authCode) throw new BadRequestException('Код подтверждения не найден или истек'); + const matches = await bcrypt.compare(command.code, authCode.codeHash); + if (!matches) throw new UnauthorizedException('Неверный код подтверждения'); + await this.prisma.authCode.update({ where: { id: authCode.id }, data: { usedAt: new Date() } }); + return { verified: true }; + } +} diff --git a/apps/sso-core/src/domain/pin.service.ts b/apps/sso-core/src/domain/pin.service.ts new file mode 100644 index 0000000..4ea04ca --- /dev/null +++ b/apps/sso-core/src/domain/pin.service.ts @@ -0,0 +1,185 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { SessionStatus } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; +import { AuthService } from './auth.service'; +import { SessionService } from './session.service'; +import { SettingsService } from './settings.service'; + +@Injectable() +export class PinService { + constructor( + private readonly prisma: PrismaService, + private readonly auth: AuthService, + private readonly settings: SettingsService, + private readonly session: SessionService + ) {} + + private async getPinLengthBounds() { + const min = await this.settings.getNumber('PIN_MIN_LENGTH', 4); + const max = await this.settings.getNumber('PIN_MAX_LENGTH', 6); + return { min, max }; + } + + private async assertValidPinFormat(pin: string) { + const { min, max } = await this.getPinLengthBounds(); + const pattern = new RegExp(`^\\d{${min},${max}}$`); + if (!pattern.test(pin)) { + throw new BadRequestException(`PIN-код должен содержать от ${min} до ${max} цифр`); + } + } + + async enablePin(userId: string, pin: string) { + await this.assertValidPinFormat(pin); + + return this.prisma.pinCode.upsert({ + where: { userId }, + create: { + userId, + hash: await bcrypt.hash(pin, 12), + isEnabled: true, + deletionRequestedAt: null + }, + update: { + hash: await bcrypt.hash(pin, 12), + isEnabled: true, + deletionRequestedAt: null + } + }); + } + + async setupPin(userId: string, pin: string) { + const pinCode = await this.enablePin(userId, pin); + return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() }; + } + + async updatePin(userId: string, pin: string) { + const pinCode = await this.enablePin(userId, pin); + await this.prisma.session.updateMany({ + where: { userId, status: SessionStatus.ACTIVE }, + data: { pinVerified: false, status: SessionStatus.LOCKED } + }); + return { userId: pinCode.userId, isEnabled: pinCode.isEnabled, deletionRequestedAt: pinCode.deletionRequestedAt?.toISOString() }; + } + + async requestPinDeletion(userId: string, pin?: string) { + const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); + if (!pinCode?.isEnabled) { + throw new BadRequestException('PIN-код не включен'); + } + + const requirePin = await this.settings.getBoolean('PIN_REQUIRE_ON_DELETE', true); + if (requirePin) { + if (!pin) { + throw new BadRequestException('Для удаления PIN-кода требуется подтверждение текущим PIN'); + } + const matches = await bcrypt.compare(pin, pinCode.hash); + if (!matches) { + throw new UnauthorizedException('Неверный PIN-код'); + } + } + + const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); + const deletionRequestedAt = new Date(); + const effectiveAt = new Date(deletionRequestedAt.getTime() + graceMinutes * 60_000); + + await this.prisma.pinCode.update({ + where: { userId }, + data: { deletionRequestedAt } + }); + + return { + userId, + deletionRequestedAt: deletionRequestedAt.toISOString(), + effectiveAt: effectiveAt.toISOString(), + graceMinutes + }; + } + + async cancelPinDeletion(userId: string) { + const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); + if (!pinCode) { + throw new BadRequestException('PIN-код не найден'); + } + await this.prisma.pinCode.update({ + where: { userId }, + data: { deletionRequestedAt: null } + }); + return { userId, cancelled: true }; + } + + async finalizeDuePinDeletions() { + const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); + const threshold = new Date(Date.now() - graceMinutes * 60_000); + const due = await this.prisma.pinCode.findMany({ + where: { + isEnabled: true, + deletionRequestedAt: { lte: threshold } + } + }); + + for (const pinCode of due) { + await this.disablePin(pinCode.userId, { skipGraceCheck: true }); + } + + return { count: due.length }; + } + + async disablePin(userId: string, options?: { skipGraceCheck?: boolean }) { + const pinCode = await this.prisma.pinCode.findUnique({ where: { userId } }); + if (!pinCode?.isEnabled) { + throw new BadRequestException('PIN-код не включен'); + } + + if (!options?.skipGraceCheck && pinCode.deletionRequestedAt) { + const graceMinutes = await this.settings.getNumber('PIN_DELETE_GRACE_MINUTES', 1440); + const effectiveAt = pinCode.deletionRequestedAt.getTime() + graceMinutes * 60_000; + if (Date.now() < effectiveAt) { + throw new BadRequestException('PIN-код будет удалён после истечения периода ожидания'); + } + } + + return this.prisma.pinCode.update({ + where: { userId }, + data: { isEnabled: false, deletionRequestedAt: null } + }); + } + + async verifyPin(sessionId: string, pin: string) { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + include: { user: { include: { pinCode: true } } } + }); + + if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) { + throw new UnauthorizedException('Сессия недействительна'); + } + + if (!session.user.pinCode?.isEnabled) { + throw new BadRequestException('PIN-код не включен'); + } + + const matches = await bcrypt.compare(pin, session.user.pinCode.hash); + if (!matches) { + throw new UnauthorizedException('Неверный PIN-код'); + } + + await this.prisma.session.update({ + where: { id: sessionId }, + data: { + pinVerified: true, + status: SessionStatus.ACTIVE, + updatedAt: new Date() + } + }); + + return { + accessToken: await this.auth.issueAccessToken(session.user, session.id, true), + pinVerified: true + }; + } + + async lockExpiredPinSessions() { + return this.session.lockExpiredPinSessions(); + } +} diff --git a/apps/sso-core/src/domain/profile.service.ts b/apps/sso-core/src/domain/profile.service.ts new file mode 100644 index 0000000..70fce77 --- /dev/null +++ b/apps/sso-core/src/domain/profile.service.ts @@ -0,0 +1,441 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; + +import * as bcrypt from 'bcryptjs'; + +import { SessionStatus, UserStatus } from '../generated/prisma/client'; + +import { PrismaService } from '../infra/prisma.service'; + + + +interface UpdateProfileCommand { + + userId: string; + + firstName?: string; + + lastName?: string; + + bio?: string; + + age?: number; + + gender?: string; + +} + + + +interface UpdateContactsCommand { + + userId: string; + + email?: string; + + phone?: string; + + backupEmail?: string; + + backupPhone?: string; + +} + + + +export function buildDeletedFieldValue(userId: string, value: string): string { + + const prefix = `is_deleted_${userId}_`; + + if (value.startsWith(prefix)) return value; + + return `${prefix}${value}`; + +} + + + +function prefixNullable(userId: string, value: string | null | undefined): string | null { + + if (!value) return null; + + return buildDeletedFieldValue(userId, value); + +} + + + +@Injectable() + +export class ProfileService { + + constructor(private readonly prisma: PrismaService) {} + + + + async getProfile(userId: string) { + + const user = await this.prisma.user.findUnique({ + + where: { id: userId }, + + include: { profile: true } + + }); + + if (!user) { + + throw new NotFoundException('Пользователь не найден'); + + } + + return this.toResponse(user); + + } + + + + async updateAvatar(userId: string, avatarUrl?: string, avatarStorageKey?: string) { + + if (!avatarStorageKey && !avatarUrl) { + + throw new BadRequestException('Укажите ключ хранилища или ссылку на аватар'); + + } + + + + if (avatarStorageKey) { + + if (!avatarStorageKey.startsWith(`avatars/${userId}/`)) { + + throw new BadRequestException('Некорректный ключ аватара'); + + } + + const user = await this.prisma.user.update({ + + where: { id: userId }, + + data: { avatarStorageKey, avatarUrl: null }, + + include: { profile: true } + + }); + + return this.toResponse(user); + + } + + + + if (!avatarUrl || !/^https?:\/\/.+/i.test(avatarUrl)) { + + throw new BadRequestException('Укажите корректную ссылку на аватар или ключ хранилища'); + + } + + const user = await this.prisma.user.update({ + + where: { id: userId }, + + data: { avatarUrl, avatarStorageKey: null }, + + include: { profile: true } + + }); + + return this.toResponse(user); + + } + + + + async updateProfile(command: UpdateProfileCommand) { + + const displayName = [command.firstName, command.lastName].filter(Boolean).join(' ').trim(); + + const user = await this.prisma.user.update({ + + where: { id: command.userId }, + + data: { + + displayName: displayName || undefined, + + gender: command.gender, + + profile: { + + upsert: { + + create: { + + firstName: command.firstName, + + lastName: command.lastName, + + bio: command.bio, + + age: command.age, + + gender: command.gender + + }, + + update: { + + firstName: command.firstName, + + lastName: command.lastName, + + bio: command.bio, + + age: command.age, + + gender: command.gender + + } + + } + + } + + }, + + include: { profile: true } + + }); + + return this.toResponse(user); + + } + + + + async updateContacts(command: UpdateContactsCommand) { + + const user = await this.prisma.user.update({ + + where: { id: command.userId }, + + data: { + + email: command.email, + + phone: command.phone, + + backupEmail: command.backupEmail, + + backupPhone: command.backupPhone + + }, + + include: { profile: true } + + }); + + return this.toResponse(user); + + } + + + + async setPassword(userId: string, password: string) { + + if (!password || password.length < 8) { + + throw new BadRequestException('Пароль должен содержать минимум 8 символов'); + + } + + const user = await this.prisma.user.update({ + + where: { id: userId }, + + data: { passwordHash: await bcrypt.hash(password, 12) } + + }); + + return { hasPassword: Boolean(user.passwordHash) }; + + } + + + + async softDeleteProfile(userId: string) { + + const user = await this.prisma.user.findUnique({ + + where: { id: userId }, + + include: { profile: true, linkedAccounts: true } + + }); + + + + if (!user) { + + throw new NotFoundException('Пользователь не найден'); + + } + + + + if (user.status === UserStatus.DELETED || user.deletedAt) { + + throw new BadRequestException('Профиль уже удалён'); + + } + + + + await this.prisma.$transaction(async (tx) => { + + await tx.session.updateMany({ + + where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } }, + + data: { status: SessionStatus.REVOKED } + + }); + + + + await tx.userRole.deleteMany({ where: { userId } }); + + + + await tx.user.update({ + + where: { id: userId }, + + data: { + + email: prefixNullable(userId, user.email), + + phone: prefixNullable(userId, user.phone), + + backupEmail: prefixNullable(userId, user.backupEmail), + + backupPhone: prefixNullable(userId, user.backupPhone), + + username: prefixNullable(userId, user.username), + + displayName: buildDeletedFieldValue(userId, user.displayName), + + avatarUrl: null, + + avatarStorageKey: null, + + passwordHash: null, + + isSuperAdmin: false, + + status: UserStatus.DELETED, + + deletedAt: new Date() + + } + + }); + + + + if (user.profile) { + + await tx.userProfile.update({ + + where: { userId }, + + data: { + + firstName: prefixNullable(userId, user.profile.firstName), + + lastName: prefixNullable(userId, user.profile.lastName), + + bio: prefixNullable(userId, user.profile.bio) + + } + + }); + + } + + + + for (const account of user.linkedAccounts) { + + await tx.linkedAccount.update({ + + where: { id: account.id }, + + data: { + + providerId: buildDeletedFieldValue(userId, account.providerId), + + email: prefixNullable(userId, account.email), + + displayName: account.displayName ? prefixNullable(userId, account.displayName) : null + + } + + }); + + } + + }); + + + + return { success: true }; + + } + + + + private toResponse(user: Awaited> & { profile?: { id?: string; firstName?: string | null; lastName?: string | null; bio?: string | null; age?: number | null; gender?: string | null } | null }) { + + if (!user) { + + throw new NotFoundException('Пользователь не найден'); + + } + + return { + + id: user.profile?.id ?? user.id, + + userId: user.id, + + displayName: user.displayName, + + avatarUrl: user.avatarUrl, + + hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl), + + firstName: user.profile?.firstName, + + lastName: user.profile?.lastName, + + bio: user.profile?.bio, + + age: user.profile?.age, + + gender: user.profile?.gender ?? user.gender, + + email: user.email, + + phone: user.phone, + + backupEmail: user.backupEmail, + + backupPhone: user.backupPhone + + }; + + } + +} + + diff --git a/apps/sso-core/src/domain/rbac.service.ts b/apps/sso-core/src/domain/rbac.service.ts new file mode 100644 index 0000000..d49c7f5 --- /dev/null +++ b/apps/sso-core/src/domain/rbac.service.ts @@ -0,0 +1,203 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; +import { OAuthClientType } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; +import { AccessService } from './access.service'; + +@Injectable() +export class RbacService { + constructor( + private readonly prisma: PrismaService, + private readonly access: AccessService + ) {} + + async createRole(actorUserId: string, data: { slug: string; name: string; description?: string; permissionSlugs?: string[] }) { + await this.access.assertSuperAdmin(actorUserId); + return this.prisma.role.create({ + data: { + slug: data.slug, + name: data.name, + description: data.description, + permissions: data.permissionSlugs?.length + ? { + create: data.permissionSlugs.map((slug) => ({ + permission: { connect: { slug } } + })) + } + : undefined + }, + include: { permissions: { include: { permission: true } } } + }); + } + + async upsertPermission(data: { slug: string; name: string; description?: string }) { + return this.prisma.permission.upsert({ + where: { slug: data.slug }, + create: data, + update: { name: data.name, description: data.description } + }); + } + + async assignRole(actorUserId: string, userId: string, roleSlug: string) { + await this.access.assertSuperAdmin(actorUserId); + const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); + if (!role) { + throw new NotFoundException('Роль не найдена'); + } + await this.ensureUserExists(userId); + await this.prisma.userRole.upsert({ + where: { userId_roleId: { userId, roleId: role.id } }, + create: { userId, roleId: role.id }, + update: {} + }); + return this.getUserRoleSlugs(userId); + } + + async removeRole(actorUserId: string, userId: string, roleSlug: string) { + await this.access.assertSuperAdmin(actorUserId); + const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); + if (!role) { + throw new NotFoundException('Роль не найдена'); + } + await this.prisma.userRole.deleteMany({ where: { userId, roleId: role.id } }); + return this.getUserRoleSlugs(userId); + } + + async listRoles() { + return this.prisma.role.findMany({ + orderBy: { name: 'asc' }, + include: { permissions: { include: { permission: true } } } + }); + } + + async listPermissions() { + return this.prisma.permission.findMany({ orderBy: { slug: 'asc' } }); + } + + async listOAuthScopes() { + return this.prisma.oAuthScope.findMany({ orderBy: { slug: 'asc' } }); + } + + async createOAuthClient(actorUserId: string, data: { name: string; redirectUris: string[]; scopes: string[]; type?: OAuthClientType }) { + await this.assertOAuthPermission(actorUserId); + if (!data.redirectUris.length) { + throw new BadRequestException('Укажите хотя бы один redirect URI'); + } + if (!data.scopes.length) { + throw new BadRequestException('Выберите хотя бы один scope'); + } + + const clientSecret = data.type === OAuthClientType.PUBLIC ? null : randomBytes(32).toString('base64url'); + const client = await this.prisma.oAuthClient.create({ + data: { + name: data.name, + clientId: `lendry_${randomBytes(16).toString('hex')}`, + clientSecret, + redirectUris: data.redirectUris, + type: data.type ?? OAuthClientType.CONFIDENTIAL, + scopes: { + create: data.scopes.map((slug) => ({ + scope: { connect: { slug } } + })) + } + }, + include: { scopes: { include: { scope: true } } } + }); + + return { client, clientSecret }; + } + + async updateOAuthClient( + actorUserId: string, + clientId: string, + data: { name?: string; redirectUris?: string[]; scopes?: string[]; isActive?: boolean } + ) { + await this.assertOAuthPermission(actorUserId); + const existing = await this.prisma.oAuthClient.findUnique({ + where: { clientId }, + include: { scopes: true } + }); + if (!existing) { + throw new NotFoundException('OAuth-приложение не найдено'); + } + + if (data.redirectUris && !data.redirectUris.length) { + throw new BadRequestException('Укажите хотя бы один redirect URI'); + } + + if (data.scopes) { + await this.prisma.oAuthClientScope.deleteMany({ where: { clientId: existing.id } }); + for (const slug of data.scopes) { + const scope = await this.prisma.oAuthScope.findUnique({ where: { slug } }); + if (!scope) continue; + await this.prisma.oAuthClientScope.create({ + data: { clientId: existing.id, scopeId: scope.id } + }); + } + } + + const updated = await this.prisma.oAuthClient.update({ + where: { id: existing.id }, + data: { + name: data.name, + redirectUris: data.redirectUris, + isActive: data.isActive + }, + include: { scopes: { include: { scope: true } } } + }); + + return { client: updated, clientSecret: undefined as string | undefined }; + } + + async rotateOAuthSecret(actorUserId: string, clientId: string) { + await this.assertOAuthPermission(actorUserId); + const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); + if (!existing) { + throw new NotFoundException('OAuth-приложение не найдено'); + } + if (existing.type === OAuthClientType.PUBLIC) { + throw new BadRequestException('У публичного клиента нет client secret'); + } + + const clientSecret = randomBytes(32).toString('base64url'); + await this.prisma.oAuthClient.update({ + where: { id: existing.id }, + data: { clientSecret } + }); + + return { clientId, clientSecret }; + } + + async listOAuthClients() { + return this.prisma.oAuthClient.findMany({ + orderBy: { createdAt: 'desc' }, + include: { scopes: { include: { scope: true } } } + }); + } + + private async assertOAuthPermission(actorUserId: string) { + const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } }); + if (!actor) { + throw new NotFoundException('Пользователь не найден'); + } + const access = await this.access.getUserAccess(actor.id, actor.isSuperAdmin); + if (!access.canManageOAuth) { + throw new ForbiddenException('Недостаточно прав для управления OAuth-приложениями'); + } + } + + private async getUserRoleSlugs(userId: string) { + const links = await this.prisma.userRole.findMany({ + where: { userId }, + include: { role: true } + }); + return links.map((link) => link.role.slug); + } + + private async ensureUserExists(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Пользователь не найден'); + } + } +} diff --git a/apps/sso-core/src/domain/security.service.ts b/apps/sso-core/src/domain/security.service.ts new file mode 100644 index 0000000..666872b --- /dev/null +++ b/apps/sso-core/src/domain/security.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { SessionStatus } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; + +@Injectable() +export class SecurityService { + constructor(private readonly prisma: PrismaService) {} + + async listActiveDevices(userId: string) { + return this.prisma.device.findMany({ + where: { userId }, + orderBy: { lastSeenAt: 'desc' }, + include: { + sessions: { + where: { status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } }, + orderBy: { updatedAt: 'desc' } + } + } + }); + } + + async listActiveSessions(userId: string) { + return this.prisma.session.findMany({ + where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } }, + orderBy: { updatedAt: 'desc' }, + include: { device: true } + }); + } + + async listSignInHistory(userId: string) { + return this.prisma.signInEvent.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 100, + include: { device: true } + }); + } + + async revokeSession(userId: string, sessionId: string) { + return this.prisma.session.update({ + where: { id: sessionId, userId }, + data: { status: SessionStatus.REVOKED } + }); + } + + async lockSession(userId: string, sessionId: string) { + return this.prisma.session.update({ + where: { id: sessionId, userId }, + data: { status: SessionStatus.LOCKED, pinVerified: false } + }); + } + + async revokeDevice(userId: string, deviceId: string) { + return this.prisma.session.updateMany({ + where: { userId, deviceId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } }, + data: { status: SessionStatus.REVOKED } + }); + } + + async revokeAllSessions(userId: string) { + return this.prisma.session.updateMany({ + where: { userId, status: { in: [SessionStatus.ACTIVE, SessionStatus.LOCKED] } }, + data: { status: SessionStatus.REVOKED } + }); + } + + async connectSocialAccount(userId: string, data: { providerName: string; providerId: string; email?: string; displayName?: string; avatarUrl?: string }) { + return this.prisma.linkedAccount.upsert({ + where: { providerName_providerId: { providerName: data.providerName, providerId: data.providerId } }, + create: { userId, ...data }, + update: { email: data.email, displayName: data.displayName, avatarUrl: data.avatarUrl } + }); + } + + async disconnectSocialAccount(userId: string, providerName: string) { + return this.prisma.linkedAccount.deleteMany({ + where: { userId, providerName } + }); + } + + async listLinkedAccounts(userId: string) { + return this.prisma.linkedAccount.findMany({ + where: { userId }, + orderBy: { providerName: 'asc' } + }); + } +} diff --git a/apps/sso-core/src/domain/session.service.ts b/apps/sso-core/src/domain/session.service.ts new file mode 100644 index 0000000..e548041 --- /dev/null +++ b/apps/sso-core/src/domain/session.service.ts @@ -0,0 +1,164 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { SessionStatus } from '../generated/prisma/client'; +import { PrismaService } from '../infra/prisma.service'; +import { AuthService } from './auth.service'; +import { SettingsService } from './settings.service'; + +export interface SessionState { + id: string; + userId: string; + pinVerified: boolean; + status: SessionStatus; + requiresPin: boolean; +} + +@Injectable() +export class SessionService { + constructor( + private readonly prisma: PrismaService, + private readonly settings: SettingsService, + private readonly auth: AuthService + ) {} + + async resolveSessionState(sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + include: { user: { include: { pinCode: true } } } + }); + + if (!session || session.status === SessionStatus.REVOKED || session.expiresAt < new Date()) { + return null; + } + + const pinEnabled = Boolean(session.user.pinCode?.isEnabled); + let pinVerified = session.pinVerified; + let status = session.status; + + if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) { + const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15); + const threshold = new Date(Date.now() - timeoutMinutes * 60_000); + if (session.updatedAt < threshold) { + await this.prisma.session.update({ + where: { id: sessionId }, + data: { pinVerified: false, status: SessionStatus.LOCKED } + }); + pinVerified = false; + status = SessionStatus.LOCKED; + } + } + + const requiresPin = pinEnabled && (!pinVerified || status === SessionStatus.LOCKED); + + return { + id: session.id, + userId: session.userId, + pinVerified, + status, + requiresPin + }; + } + + async touchSessionActivity(sessionId: string) { + const state = await this.resolveSessionState(sessionId); + if (!state || state.requiresPin) { + return state; + } + + await this.prisma.session.update({ + where: { id: sessionId }, + data: { updatedAt: new Date() } + }); + + return state; + } + + async validateSession(userId: string, sessionId: string, touchActivity: boolean) { + const state = touchActivity + ? await this.touchSessionActivity(sessionId) + : await this.resolveSessionState(sessionId); + + if (!state || state.userId !== userId) { + throw new UnauthorizedException('Сессия недействительна'); + } + + return { + requiresPin: state.requiresPin, + sessionId: state.id, + pinVerified: state.pinVerified + }; + } + + async refreshSession(refreshToken: string, sessionId?: string) { + const sessions = await this.prisma.session.findMany({ + where: { + ...(sessionId ? { id: sessionId } : {}), + status: { not: SessionStatus.REVOKED }, + expiresAt: { gt: new Date() } + }, + include: { user: { include: { pinCode: true } } }, + orderBy: { updatedAt: 'desc' }, + take: sessionId ? 1 : 50 + }); + + let matchedSession: (typeof sessions)[number] | null = null; + for (const session of sessions) { + const matches = await bcrypt.compare(refreshToken, session.refreshHash); + if (matches) { + matchedSession = session; + break; + } + } + + if (!matchedSession) { + throw new UnauthorizedException('Refresh token недействителен'); + } + + const state = await this.resolveSessionState(matchedSession.id); + if (!state) { + throw new UnauthorizedException('Сессия недействительна'); + } + + if (state.requiresPin) { + const user = await this.auth.getMe(matchedSession.userId); + return { + requiresPin: true, + sessionId: state.id, + pinVerified: false, + user + }; + } + + await this.prisma.session.update({ + where: { id: matchedSession.id }, + data: { updatedAt: new Date() } + }); + + const user = await this.auth.getMe(matchedSession.userId); + + return { + requiresPin: false, + accessToken: await this.auth.issueAccessToken(matchedSession.user, matchedSession.id, true), + sessionId: matchedSession.id, + pinVerified: true, + user + }; + } + + async lockExpiredPinSessions() { + const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15); + const threshold = new Date(Date.now() - timeoutMinutes * 60_000); + return this.prisma.session.updateMany({ + where: { + pinVerified: true, + updatedAt: { lt: threshold }, + status: SessionStatus.ACTIVE, + user: { pinCode: { isEnabled: true } } + }, + data: { + pinVerified: false, + status: SessionStatus.LOCKED + } + }); + } +} diff --git a/apps/sso-core/src/domain/settings.service.ts b/apps/sso-core/src/domain/settings.service.ts new file mode 100644 index 0000000..ca85d21 --- /dev/null +++ b/apps/sso-core/src/domain/settings.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; +import { UpsertSettingCommand } from './dto'; +import { PUBLIC_SETTING_KEYS } from './system-settings.seed'; + +@Injectable() +export class SettingsService { + constructor(private readonly prisma: PrismaService) {} + + async getValue(key: string, fallback: string): Promise { + const setting = await this.prisma.systemSetting.findUnique({ where: { key } }); + return setting?.value ?? fallback; + } + + async getNumber(key: string, fallback: number): Promise { + const raw = await this.getValue(key, String(fallback)); + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; + } + + async getBoolean(key: string, fallback: boolean): Promise { + const raw = (await this.getValue(key, fallback ? 'true' : 'false')).trim().toLowerCase(); + if (raw === 'true' || raw === '1' || raw === 'yes') return true; + if (raw === 'false' || raw === '0' || raw === 'no') return false; + return fallback; + } + + async listPublic() { + return this.prisma.systemSetting.findMany({ + where: { key: { in: [...PUBLIC_SETTING_KEYS] }, isSecret: false }, + orderBy: { key: 'asc' } + }); + } + + async list() { + return this.prisma.systemSetting.findMany({ orderBy: { key: 'asc' } }); + } + + async get(key: string) { + return this.prisma.systemSetting.findUniqueOrThrow({ where: { key } }); + } + + async upsert(command: UpsertSettingCommand) { + return this.prisma.systemSetting.upsert({ + where: { key: command.key }, + create: { + key: command.key, + value: command.value, + description: command.description, + isSecret: command.isSecret ?? false + }, + update: { + value: command.value, + description: command.description, + isSecret: command.isSecret ?? false + } + }); + } + + async delete(key: string) { + await this.prisma.systemSetting.delete({ where: { key } }); + return { count: 1 }; + } + + async listSocialProviders() { + return this.prisma.socialProvider.findMany({ orderBy: { providerName: 'asc' } }); + } + + async upsertSocialProvider(command: { providerName: string; clientId: string; clientSecret?: string; isEnabled: boolean }) { + return this.prisma.socialProvider.upsert({ + where: { providerName: command.providerName }, + create: command, + update: { + clientId: command.clientId, + clientSecret: command.clientSecret, + isEnabled: command.isEnabled + } + }); + } + + async deleteSocialProvider(providerName: string) { + await this.prisma.socialProvider.delete({ where: { providerName } }); + return { count: 1 }; + } +} diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts new file mode 100644 index 0000000..8746518 --- /dev/null +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -0,0 +1,67 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; + +export const DEFAULT_SYSTEM_SETTINGS = [ + { key: 'PROJECT_NAME', value: 'MVK ID', description: 'Название проекта в интерфейсе и заголовке страницы' }, + { key: 'PROJECT_TAGLINE', value: 'Единый аккаунт для сервисов Lendry', description: 'Краткий слоган под названием проекта' }, + { key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP' }, + { key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' }, + { key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' }, + { key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' }, + { key: 'PIN_MIN_LENGTH', value: '4', description: 'Минимальная длина PIN-кода' }, + { key: 'PIN_MAX_LENGTH', value: '6', description: 'Максимальная длина PIN-кода' }, + { key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' }, + { key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' }, + { key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' }, + { key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' }, + { key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' }, + { key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' }, + { key: 'PASSWORD_MIN_LENGTH', value: '8', description: 'Минимальная длина пароля' }, + { key: 'MAX_ACTIVE_SESSIONS', value: '20', description: 'Максимум одновременных активных сессий на пользователя' }, + { key: 'MAINTENANCE_MODE', value: 'false', description: 'Режим обслуживания: блокирует вход для обычных пользователей' }, + { key: 'LDAP_ENABLED', value: 'false', description: 'Разрешить вход через LDAP/LDAPS' }, + { key: 'LDAP_USE_LDAPS', value: 'false', description: 'Использовать LDAPS (порт 636) вместо LDAP (порт 389)' }, + { key: 'LDAP_HOST', value: '', description: 'Хост LDAP: один сервер или несколько через запятую. Можно ldaps://dc-1.local:636' }, + { key: 'LDAP_PORT', value: '636', description: 'Порт LDAP-сервера (389 для LDAP, 636 для LDAPS)' }, + { key: 'LDAP_DOMAIN', value: '', description: 'Домен AD для UPN bind, например mvkug.local' }, + { key: 'LDAP_SKIP_TLS_VERIFY', value: 'false', description: 'Не проверять TLS-сертификат LDAPS (только для тестов)' }, + { key: 'LDAP_BASE_DN', value: '', description: 'Base DN для поиска пользователей, например OU=Lugansk,DC=example,DC=com' }, + { key: 'LDAP_BIND_USERNAME', value: '', description: 'Логин сервисной учётной записи для bind (например mvkadmin)' }, + { key: 'LDAP_BIND_DN', value: '', description: 'Полный Bind DN (опционально). Если указан, имеет приоритет над логином' }, + { key: 'LDAP_BIND_PASSWORD', value: '', description: 'Пароль сервисной учётной записи LDAP' }, + { + key: 'LDAP_USER_FILTER', + value: '(|(sAMAccountName={username})(userPrincipalName={username})(mail={username})(uid={username}))', + description: 'LDAP-фильтр поиска пользователя. Используйте {username} как placeholder' + }, + { key: 'LDAP_USERNAME_ATTR', value: 'sAMAccountName', description: 'Атрибут LDAP с логином пользователя' }, + { key: 'LDAP_EMAIL_ATTR', value: 'mail', description: 'Атрибут LDAP с почтой пользователя' }, + { key: 'LDAP_DISPLAY_NAME_ATTR', value: 'displayName', description: 'Атрибут LDAP с отображаемым именем' } +] as const; + +const SECRET_SETTING_KEYS = new Set(['LDAP_BIND_PASSWORD']); + +export const PUBLIC_SETTING_KEYS = [ + 'PROJECT_NAME', + 'PROJECT_TAGLINE', + 'PROJECT_DOMAIN', + 'REGISTRATION_ENABLED', + 'MAINTENANCE_MODE', + 'LDAP_ENABLED', + 'LDAP_USE_LDAPS' +] as const; + +@Injectable() +export class SystemSettingsSeedService implements OnModuleInit { + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit() { + for (const setting of DEFAULT_SYSTEM_SETTINGS) { + await this.prisma.systemSetting.upsert({ + where: { key: setting.key }, + create: { ...setting, isSecret: SECRET_SETTING_KEYS.has(setting.key) }, + update: { description: setting.description, isSecret: SECRET_SETTING_KEYS.has(setting.key) } + }); + } + } +} diff --git a/apps/sso-core/src/health.controller.ts b/apps/sso-core/src/health.controller.ts new file mode 100644 index 0000000..60168d3 --- /dev/null +++ b/apps/sso-core/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok', service: 'sso-core' }; + } +} diff --git a/apps/sso-core/src/infra/chat-media.util.ts b/apps/sso-core/src/infra/chat-media.util.ts new file mode 100644 index 0000000..cf3b3cf --- /dev/null +++ b/apps/sso-core/src/infra/chat-media.util.ts @@ -0,0 +1,206 @@ +const BLOCKED_CHAT_MEDIA_TYPES = new Set([ + 'application/javascript', + 'application/x-javascript', + 'text/javascript', + 'text/html', + 'application/xhtml+xml', + 'application/x-httpd-php', + 'application/x-sh', + 'application/x-msdownload' +]); + +const EXTENSION_TO_MIME: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + ogg: 'audio/ogg', + opus: 'audio/ogg', + webm: 'audio/webm', + wav: 'audio/wav', + aac: 'audio/aac', + flac: 'audio/flac', + mp4: 'video/mp4', + mov: 'video/quicktime', + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + txt: 'text/plain', + csv: 'text/csv', + zip: 'application/zip', + rar: 'application/vnd.rar', + '7z': 'application/x-7z-compressed', + json: 'application/json', + xml: 'application/xml' +}; + +const MIME_ALIASES: Record = { + 'audio/x-m4a': 'audio/mp4', + 'audio/x-aac': 'audio/aac', + 'audio/x-wav': 'audio/wav', + 'audio/x-flac': 'audio/flac', + 'audio/x-mpeg': 'audio/mpeg', + 'audio/mp3': 'audio/mpeg', + 'image/jpg': 'image/jpeg', + 'image/pjpeg': 'image/jpeg', + 'application/x-pdf': 'application/pdf', + 'application/x-zip-compressed': 'application/zip' +}; + +export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE'; + +export function normalizeChatContentType(contentType?: string | null, fileName?: string | null) { + const raw = (contentType ?? '').trim().toLowerCase(); + const base = raw.split(';')[0]?.trim() ?? ''; + const aliased = MIME_ALIASES[base] ?? base; + + if (aliased && aliased !== 'application/octet-stream') { + return aliased; + } + + const extension = extractFileExtension(fileName); + if (extension && EXTENSION_TO_MIME[extension]) { + return EXTENSION_TO_MIME[extension]; + } + + return aliased || 'application/octet-stream'; +} + +export function assertChatMediaContentType(contentType?: string | null, fileName?: string | null) { + const normalized = normalizeChatContentType(contentType, fileName); + if (BLOCKED_CHAT_MEDIA_TYPES.has(normalized)) { + throw new Error('Этот тип файла нельзя отправить в чат'); + } + + const [category] = normalized.split('/'); + if (category === 'image' || category === 'audio' || category === 'video') { + return normalized; + } + + const allowedApplicationTypes = new Set([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip', + 'application/vnd.rar', + 'application/x-7z-compressed', + 'application/json', + 'application/xml', + 'application/octet-stream' + ]); + + if (category === 'text' || allowedApplicationTypes.has(normalized)) { + return normalized; + } + + throw new Error('Неподдерживаемый тип медиафайла для чата'); +} + +export function extensionForChatContentType(contentType: string, fileName?: string | null) { + const normalized = normalizeChatContentType(contentType, fileName); + switch (normalized) { + case 'image/jpeg': + return 'jpg'; + case 'image/png': + return 'png'; + case 'image/webp': + return 'webp'; + case 'image/gif': + return 'gif'; + case 'image/heic': + return 'heic'; + case 'image/heif': + return 'heif'; + case 'audio/mpeg': + return 'mp3'; + case 'audio/mp4': + return 'm4a'; + case 'audio/ogg': + return 'ogg'; + case 'audio/webm': + return 'webm'; + case 'audio/wav': + return 'wav'; + case 'audio/aac': + return 'aac'; + case 'audio/flac': + return 'flac'; + case 'video/mp4': + return 'mp4'; + case 'video/quicktime': + return 'mov'; + case 'application/pdf': + return 'pdf'; + case 'application/msword': + return 'doc'; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return 'docx'; + case 'application/vnd.ms-excel': + return 'xls'; + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + return 'xlsx'; + case 'application/vnd.ms-powerpoint': + return 'ppt'; + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + return 'pptx'; + case 'text/plain': + return 'txt'; + case 'text/csv': + return 'csv'; + case 'application/zip': + return 'zip'; + case 'application/vnd.rar': + return 'rar'; + case 'application/x-7z-compressed': + return '7z'; + case 'application/json': + return 'json'; + case 'application/xml': + return 'xml'; + default: { + const fromName = extractFileExtension(fileName); + if (fromName) return fromName; + return 'bin'; + } + } +} + +export function contentTypeForStorageKey(storageKey: string) { + const extension = extractFileExtension(storageKey); + if (extension && EXTENSION_TO_MIME[extension]) { + return EXTENSION_TO_MIME[extension]; + } + if (storageKey.endsWith('.bin')) return 'application/octet-stream'; + return 'application/octet-stream'; +} + +export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind { + const normalized = normalizeChatContentType(contentType); + if (normalized.startsWith('image/')) return 'IMAGE'; + if (normalized.startsWith('video/')) return 'FILE'; + if (normalized.startsWith('audio/')) { + return options?.voice ? 'VOICE' : 'AUDIO'; + } + return 'FILE'; +} + +function extractFileExtension(value?: string | null) { + if (!value) return ''; + const clean = value.split('?')[0]?.split('#')[0] ?? value; + const parts = clean.split('.'); + if (parts.length < 2) return ''; + return parts.pop()?.trim().toLowerCase() ?? ''; +} diff --git a/apps/sso-core/src/infra/encryption.service.ts b/apps/sso-core/src/infra/encryption.service.ts new file mode 100644 index 0000000..d1733c6 --- /dev/null +++ b/apps/sso-core/src/infra/encryption.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +@Injectable() +export class EncryptionService { + private readonly key = createHash('sha256') + .update(process.env.DATA_ENCRYPTION_KEY ?? 'local-development-data-encryption-key') + .digest(); + + encrypt(value: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('base64url')}.${authTag.toString('base64url')}.${encrypted.toString('base64url')}`; + } + + decrypt(value: string): string { + const [ivRaw, authTagRaw, encryptedRaw] = value.split('.'); + if (!ivRaw || !authTagRaw || !encryptedRaw) { + throw new Error('Некорректный формат зашифрованных данных'); + } + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivRaw, 'base64url')); + decipher.setAuthTag(Buffer.from(authTagRaw, 'base64url')); + const decrypted = Buffer.concat([decipher.update(Buffer.from(encryptedRaw, 'base64url')), decipher.final()]); + return decrypted.toString('utf8'); + } +} diff --git a/apps/sso-core/src/infra/grpc-exception.filter.ts b/apps/sso-core/src/infra/grpc-exception.filter.ts new file mode 100644 index 0000000..ed593cb --- /dev/null +++ b/apps/sso-core/src/infra/grpc-exception.filter.ts @@ -0,0 +1,47 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; +import { RpcException } from '@nestjs/microservices'; +import { status as GrpcStatus } from '@grpc/grpc-js'; +import { Observable, throwError } from 'rxjs'; + +function httpToGrpc(code: number): number { + switch (code) { + case 400: + return GrpcStatus.INVALID_ARGUMENT; + case 401: + return GrpcStatus.UNAUTHENTICATED; + case 403: + return GrpcStatus.PERMISSION_DENIED; + case 404: + return GrpcStatus.NOT_FOUND; + case 409: + return GrpcStatus.ALREADY_EXISTS; + default: + return GrpcStatus.UNKNOWN; + } +} + +@Catch() +export class GrpcExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): Observable { + if (host.getType() !== 'rpc') { + throw exception; + } + + let code = GrpcStatus.UNKNOWN; + let message = 'Внутренняя ошибка сервиса'; + + if (exception instanceof HttpException) { + const response = exception.getResponse(); + const raw = typeof response === 'string' ? response : (response as { message?: string | string[] })?.message ?? exception.message; + message = Array.isArray(raw) ? raw.join('; ') : String(raw); + code = httpToGrpc(exception.getStatus()); + } else if (exception instanceof RpcException) { + const error = exception.getError(); + message = typeof error === 'string' ? error : ((error as { message?: string })?.message ?? message); + } else if (exception && typeof exception === 'object' && 'message' in exception) { + message = String((exception as { message: unknown }).message); + } + + return throwError(() => ({ code, message, details: message })); + } +} diff --git a/apps/sso-core/src/infra/ldap-client.service.ts b/apps/sso-core/src/infra/ldap-client.service.ts new file mode 100644 index 0000000..04eb7d7 --- /dev/null +++ b/apps/sso-core/src/infra/ldap-client.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface LdapConnectionConfig { + host: string; + port: number; + useLdaps: boolean; + skipTlsVerify: boolean; + domain: string; + baseDn: string; + bindDn: string; + bindPassword: string; + userFilter: string; + usernameAttr: string; + emailAttr: string; + displayNameAttr: string; +} + +export interface LdapAuthResult { + dn: string; + username: string; + email?: string; + displayName: string; +} + +@Injectable() +export class LdapClientService { + private readonly logger = new Logger(LdapClientService.name); + + constructor(private readonly config: ConfigService) {} + + private get baseUrl() { + return this.config.get('LDAP_AUTH_URL', 'http://ldap-auth:8086').replace(/\/$/, ''); + } + + async authenticate(config: LdapConnectionConfig, username: string, password: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, username, password }) + }); + + const payload = (await response.json()) as { + success?: boolean; + dn?: string; + username?: string; + email?: string; + displayName?: string; + error?: string; + }; + + if (!response.ok || !payload.success || !payload.dn) { + throw new Error(payload.error || 'Не удалось выполнить LDAP-аутентификацию'); + } + + return { + dn: payload.dn, + username: payload.username ?? username, + email: payload.email, + displayName: payload.displayName ?? payload.username ?? username + }; + } catch (error) { + this.logger.warn(`LDAP auth failed: ${error instanceof Error ? error.message : error}`); + throw error; + } + } +} diff --git a/apps/sso-core/src/infra/ldap-config.util.ts b/apps/sso-core/src/infra/ldap-config.util.ts new file mode 100644 index 0000000..97eb59c --- /dev/null +++ b/apps/sso-core/src/infra/ldap-config.util.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from '@nestjs/common'; + +export function resolveLdapPort(port: number, useLdaps: boolean): number { + if (useLdaps) { + if (port === 640 || port === 389 || port === 0) { + return 636; + } + return port; + } + if (port === 0) { + return 389; + } + return port; +} + +export function normalizeLdapUserFilter(filter: string): string { + return filter.replace(/\{\{username\}\}/g, '{username}').replace(/\{user\}/g, '{username}'); +} + +export function resolveLdapBindIdentity(bindUsername: string, bindDn: string, domain: string): string { + const fullDn = bindDn.trim(); + if (fullDn.includes('=')) { + return fullDn; + } + + const username = bindUsername.trim() || fullDn; + if (!username) { + return ''; + } + if (username.includes('\\') || username.includes('@')) { + return username; + } + if (domain.trim()) { + return `${username}@${domain.trim()}`; + } + return username; +} + +/** @deprecated use resolveLdapBindIdentity */ +export function resolveLdapBindDn(bindDn: string, domain: string): string { + return resolveLdapBindIdentity('', bindDn, domain); +} + +export function validateLdapConfig(config: { + host: string; + port: number; + useLdaps: boolean; + baseDn: string; + bindDn: string; + bindPassword: string; +}) { + if (!config.host.trim()) { + throw new BadRequestException('Не указан LDAP host'); + } + if (!config.baseDn.trim()) { + throw new BadRequestException('Не указан LDAP Base DN'); + } + if (!config.bindDn.trim()) { + throw new BadRequestException('Укажите логин сервисной учётной записи или Bind DN'); + } + if (!config.bindPassword.trim()) { + throw new BadRequestException('Не указан пароль сервисной учётной записи LDAP'); + } + if (config.useLdaps && config.port === 640) { + throw new BadRequestException('Порт 640 для LDAPS неверен. Используйте 636.'); + } +} diff --git a/apps/sso-core/src/infra/minio.service.ts b/apps/sso-core/src/infra/minio.service.ts new file mode 100644 index 0000000..1b29c25 --- /dev/null +++ b/apps/sso-core/src/infra/minio.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + CreateBucketCommand, + HeadBucketCommand, + PutBucketCorsCommand, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { + assertChatMediaContentType as assertChatMedia, + contentTypeForStorageKey, + extensionForChatContentType, + normalizeChatContentType +} from './chat-media.util'; + +const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); + +const BROWSER_CORS_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:3002', + 'http://127.0.0.1:3000', + 'http://127.0.0.1:3002' +]; + +@Injectable() +export class MinioService implements OnModuleInit { + private readonly logger = new Logger(MinioService.name); + private internalClient!: S3Client; + private presignClient!: S3Client; + private bucket!: string; + + constructor(private readonly config: ConfigService) {} + + private createClient(endpoint: string) { + const useSsl = this.config.get('MINIO_USE_SSL', 'false') === 'true'; + return new S3Client({ + endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`, + region: this.config.get('MINIO_REGION', 'us-east-1'), + credentials: { + accessKeyId: this.config.get('MINIO_ACCESS_KEY', 'minioadmin'), + secretAccessKey: this.config.get('MINIO_SECRET_KEY', 'minioadmin') + }, + forcePathStyle: true + }); + } + + async onModuleInit() { + const internalEndpoint = this.config.get('MINIO_ENDPOINT', 'localhost:9000'); + const publicEndpoint = this.config.get('MINIO_PUBLIC_ENDPOINT', internalEndpoint); + this.bucket = this.config.get('MINIO_BUCKET', 'lendry-id'); + + this.internalClient = this.createClient(internalEndpoint); + this.presignClient = this.createClient(publicEndpoint); + + try { + await this.internalClient.send(new HeadBucketCommand({ Bucket: this.bucket })); + } catch { + await this.internalClient.send(new CreateBucketCommand({ Bucket: this.bucket })); + this.logger.log(`Создан bucket ${this.bucket}`); + } + + await this.configureCors(); + this.logger.log(`MinIO: internal=${internalEndpoint}, public=${publicEndpoint}`); + } + + private async configureCors() { + try { + await this.internalClient.send( + new PutBucketCorsCommand({ + Bucket: this.bucket, + CORSConfiguration: { + CORSRules: [ + { + AllowedHeaders: ['*'], + AllowedMethods: ['GET', 'PUT', 'POST', 'HEAD'], + AllowedOrigins: BROWSER_CORS_ORIGINS, + ExposeHeaders: ['ETag'], + MaxAgeSeconds: 3600 + } + ] + } + }) + ); + } catch (error) { + this.logger.warn(`Не удалось настроить CORS для bucket ${this.bucket}: ${error instanceof Error ? error.message : error}`); + } + } + + assertImageContentType(contentType: string) { + if (!ALLOWED_IMAGE_TYPES.has(contentType)) { + throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF'); + } + } + + assertChatMediaContentType(contentType: string, fileName?: string) { + return assertChatMedia(contentType, fileName); + } + + extensionForChatMedia(contentType: string, fileName?: string) { + return extensionForChatContentType(normalizeChatContentType(contentType, fileName), fileName); + } + + extensionForContentType(contentType: string) { + switch (contentType) { + case 'image/jpeg': + return 'jpg'; + case 'image/png': + return 'png'; + case 'image/webp': + return 'webp'; + case 'image/gif': + return 'gif'; + case 'audio/mpeg': + return 'mp3'; + case 'audio/mp4': + return 'm4a'; + case 'audio/ogg': + return 'ogg'; + case 'audio/webm': + case 'video/webm': + return 'webm'; + case 'audio/wav': + return 'wav'; + default: + return 'bin'; + } + } + + contentTypeForKey(storageKey: string) { + if (storageKey.startsWith('chat/')) { + return contentTypeForStorageKey(storageKey); + } + if (storageKey.endsWith('.png')) return 'image/png'; + if (storageKey.endsWith('.webp')) return 'image/webp'; + if (storageKey.endsWith('.gif')) return 'image/gif'; + if (storageKey.endsWith('.mp3')) return 'audio/mpeg'; + if (storageKey.endsWith('.m4a')) return 'audio/mp4'; + if (storageKey.endsWith('.ogg')) return 'audio/ogg'; + if (storageKey.endsWith('.webm')) return 'audio/webm'; + if (storageKey.endsWith('.wav')) return 'audio/wav'; + return 'image/jpeg'; + } + + async createPresignedUploadUrl(storageKey: string, contentType: string, expiresInSeconds = 300) { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: storageKey, + ContentType: contentType + }); + const uploadUrl = await getSignedUrl(this.presignClient, command, { expiresIn: expiresInSeconds }); + return { + uploadUrl, + expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString() + }; + } + + getClient() { + return this.internalClient; + } + + getBucket() { + return this.bucket; + } +} diff --git a/apps/sso-core/src/infra/notification-publisher.service.ts b/apps/sso-core/src/infra/notification-publisher.service.ts new file mode 100644 index 0000000..f48c68d --- /dev/null +++ b/apps/sso-core/src/infra/notification-publisher.service.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import amqp from 'amqplib'; + +export interface RealtimeNotificationPayload { + userId: string; + type: string; + title: string; + message: string; + payload?: Record; +} + +@Injectable() +export class NotificationPublisherService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(NotificationPublisherService.name); + private connection: { createChannel: () => Promise; close: () => Promise } | null = null; + private channel: amqp.ConfirmChannel | amqp.Channel | null = null; + private readonly queueName = 'id.notifications'; + + constructor(private readonly config: ConfigService) {} + + async onModuleInit() { + await this.connect(); + } + + async onModuleDestroy() { + try { + await this.channel?.close(); + } catch { + // ignore shutdown errors + } + try { + await this.connection?.close(); + } catch { + // ignore shutdown errors + } + } + + private async connect() { + const url = this.config.get('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/'); + try { + this.connection = (await amqp.connect(url)) as { + createChannel: () => Promise; + close: () => Promise; + }; + this.channel = await this.connection.createChannel(); + await this.channel.assertQueue(this.queueName, { durable: true }); + this.logger.log('Подключение к RabbitMQ для уведомлений установлено'); + } catch (error) { + this.logger.warn(`RabbitMQ недоступен, push-уведомления будут только в БД: ${error instanceof Error ? error.message : error}`); + } + } + + async publish(notification: RealtimeNotificationPayload) { + if (!this.channel) { + await this.connect(); + } + if (!this.channel) return; + + const body = Buffer.from( + JSON.stringify({ + userId: notification.userId, + type: notification.type, + title: notification.title, + message: notification.message, + payload: notification.payload ?? {} + }) + ); + + try { + this.channel.sendToQueue(this.queueName, body, { persistent: true }); + } catch (error) { + this.logger.warn(`Не удалось отправить уведомление в RabbitMQ: ${error instanceof Error ? error.message : error}`); + } + } +} diff --git a/apps/sso-core/src/infra/prisma.service.ts b/apps/sso-core/src/infra/prisma.service.ts new file mode 100644 index 0000000..ac4b43e --- /dev/null +++ b/apps/sso-core/src/infra/prisma.service.ts @@ -0,0 +1,22 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const datasourceUrl = process.env.DATABASE_URL; + if (!datasourceUrl) { + throw new Error('Не задан DATABASE_URL для подключения к базе данных'); + } + super({ adapter: new PrismaPg({ connectionString: datasourceUrl }) }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/sso-core/src/infra/redis.service.ts b/apps/sso-core/src/infra/redis.service.ts new file mode 100644 index 0000000..29ee4f5 --- /dev/null +++ b/apps/sso-core/src/infra/redis.service.ts @@ -0,0 +1,28 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleDestroy { + readonly client: Redis; + + constructor(config: ConfigService) { + this.client = new Redis(config.get('REDIS_URL', 'redis://localhost:6379'), { + maxRetriesPerRequest: 3, + lazyConnect: true + }); + } + + async acquireLock(key: string, ttlMs: number): Promise { + const result = await this.client.set(key, '1', 'PX', ttlMs, 'NX'); + return result === 'OK'; + } + + async releaseLock(key: string): Promise { + await this.client.del(key); + } + + async onModuleDestroy() { + await this.client.quit(); + } +} diff --git a/apps/sso-core/src/main.ts b/apps/sso-core/src/main.ts new file mode 100644 index 0000000..ed058fc --- /dev/null +++ b/apps/sso-core/src/main.ts @@ -0,0 +1,46 @@ +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { join } from 'node:path'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const config = app.get(ConfigService); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true + }) + ); + + app.connectMicroservice({ + 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.getOrThrow('GRPC_URL') + } + }); + + await app.startAllMicroservices(); + await app.listen(config.get('HTTP_PORT', 3001)); + Logger.log('SSO Core запущен', 'Bootstrap'); +} + +void bootstrap(); diff --git a/apps/sso-core/tsconfig.json b/apps/sso-core/tsconfig.json new file mode 100644 index 0000000..0719f4e --- /dev/null +++ b/apps/sso-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}