fix sso core folder
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
|||||||
.turbo
|
.turbo
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
.cursorrules
|
||||||
!.env.example
|
!.env.example
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
.idp-install.json
|
.idp-install.json
|
||||||
|
|||||||
Submodule apps/sso-core deleted from 0192b0c72e
5
apps/sso-core/.env.example
Normal file
5
apps/sso-core/.env.example
Normal file
@@ -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"
|
||||||
46
apps/sso-core/Dockerfile
Normal file
46
apps/sso-core/Dockerfile
Normal file
@@ -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"]
|
||||||
8
apps/sso-core/nest-cli.json
Normal file
8
apps/sso-core/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/sso-core/package.json
Normal file
49
apps/sso-core/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/sso-core/prisma.config.ts
Normal file
12
apps/sso-core/prisma.config.ts
Normal file
@@ -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')
|
||||||
|
}
|
||||||
|
});
|
||||||
481
apps/sso-core/prisma/schema.prisma
Normal file
481
apps/sso-core/prisma/schema.prisma
Normal file
@@ -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
|
||||||
|
}
|
||||||
9
apps/sso-core/scripts/copy-generated.mjs
Normal file
9
apps/sso-core/scripts/copy-generated.mjs
Normal file
@@ -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 });
|
||||||
81
apps/sso-core/scripts/generate-proto-types.mjs
Normal file
81
apps/sso-core/scripts/generate-proto-types.mjs
Normal file
@@ -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');
|
||||||
68
apps/sso-core/src/app.module.ts
Normal file
68
apps/sso-core/src/app.module.ts
Normal file
@@ -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 {}
|
||||||
90
apps/sso-core/src/domain/access.service.ts
Normal file
90
apps/sso-core/src/domain/access.service.ts
Normal file
@@ -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<UserAccessContext> {
|
||||||
|
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('Нет доступа к документам пользователя');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/sso-core/src/domain/addresses.service.ts
Normal file
145
apps/sso-core/src/domain/addresses.service.ts
Normal file
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
93
apps/sso-core/src/domain/admin-seed.service.ts
Normal file
93
apps/sso-core/src/domain/admin-seed.service.ts
Normal file
@@ -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: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
apps/sso-core/src/domain/admin.service.ts
Normal file
136
apps/sso-core/src/domain/admin.service.ts
Normal file
@@ -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('Пользователь не найден');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/sso-core/src/domain/advanced-auth.service.ts
Normal file
37
apps/sso-core/src/domain/advanced-auth.service.ts
Normal file
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
787
apps/sso-core/src/domain/auth-grpc.controller.ts
Normal file
787
apps/sso-core/src/domain/auth-grpc.controller.ts
Normal file
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
519
apps/sso-core/src/domain/auth.service.ts
Normal file
519
apps/sso-core/src/domain/auth.service.ts
Normal file
@@ -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<PublicUser> {
|
||||||
|
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<AuthTokens> {
|
||||||
|
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<string, string | null> = {
|
||||||
|
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<AuthTokens> {
|
||||||
|
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<LdapConnectionConfig> {
|
||||||
|
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<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) {
|
||||||
|
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<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
|
||||||
|
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<string>('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<string>('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<PublicUser> {
|
||||||
|
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<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status'>): Promise<PublicUser> {
|
||||||
|
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<User, 'id' | 'isSuperAdmin'>, sessionId: string, pinVerified: boolean) {
|
||||||
|
return this.jwt.signAsync(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
sessionId,
|
||||||
|
pinVerified,
|
||||||
|
isSuperAdmin: user.isSuperAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.config.getOrThrow<string>('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';
|
||||||
|
}
|
||||||
|
}
|
||||||
457
apps/sso-core/src/domain/chat.service.ts
Normal file
457
apps/sso-core/src/domain/chat.service.ts
Normal file
@@ -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<ReturnType<ChatService['toMessage']>>
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
140
apps/sso-core/src/domain/documents.service.ts
Normal file
140
apps/sso-core/src/domain/documents.service.ts
Normal file
@@ -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<ReturnType<PrismaService['userDocument']['create']>>) {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/sso-core/src/domain/dto.ts
Normal file
61
apps/sso-core/src/domain/dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
786
apps/sso-core/src/domain/family.service.ts
Normal file
786
apps/sso-core/src/domain/family.service.ts
Normal file
@@ -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()
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
283
apps/sso-core/src/domain/media.service.ts
Normal file
283
apps/sso-core/src/domain/media.service.ts
Normal file
@@ -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<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicApiUrl = this.config.get<string>('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<MediaStreamPayload>(token, {
|
||||||
|
secret: this.config.getOrThrow<string>('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<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicApiUrl = this.config.get<string>('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<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const publicApiUrl = this.config.get<string>('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('Нет доступа к чату');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
apps/sso-core/src/domain/notifications.service.ts
Normal file
166
apps/sso-core/src/domain/notifications.service.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string>();
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/sso-core/src/domain/oauth-core.service.ts
Normal file
78
apps/sso-core/src/domain/oauth-core.service.ts
Normal file
@@ -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<string>('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<string>('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<string>('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<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer: 'id.lendry.ru' });
|
||||||
|
const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer: 'id.lendry.ru' });
|
||||||
|
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/sso-core/src/domain/otp.service.ts
Normal file
49
apps/sso-core/src/domain/otp.service.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
185
apps/sso-core/src/domain/pin.service.ts
Normal file
185
apps/sso-core/src/domain/pin.service.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
441
apps/sso-core/src/domain/profile.service.ts
Normal file
441
apps/sso-core/src/domain/profile.service.ts
Normal file
@@ -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<ReturnType<PrismaService['user']['findUnique']>> & { 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
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
203
apps/sso-core/src/domain/rbac.service.ts
Normal file
203
apps/sso-core/src/domain/rbac.service.ts
Normal file
@@ -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('Пользователь не найден');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/sso-core/src/domain/security.service.ts
Normal file
87
apps/sso-core/src/domain/security.service.ts
Normal file
@@ -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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/sso-core/src/domain/session.service.ts
Normal file
164
apps/sso-core/src/domain/session.service.ts
Normal file
@@ -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<SessionState | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
85
apps/sso-core/src/domain/settings.service.ts
Normal file
85
apps/sso-core/src/domain/settings.service.ts
Normal file
@@ -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<string> {
|
||||||
|
const setting = await this.prisma.systemSetting.findUnique({ where: { key } });
|
||||||
|
return setting?.value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNumber(key: string, fallback: number): Promise<number> {
|
||||||
|
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<boolean> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/sso-core/src/domain/system-settings.seed.ts
Normal file
67
apps/sso-core/src/domain/system-settings.seed.ts
Normal file
@@ -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) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/sso-core/src/health.controller.ts
Normal file
9
apps/sso-core/src/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok', service: 'sso-core' };
|
||||||
|
}
|
||||||
|
}
|
||||||
206
apps/sso-core/src/infra/chat-media.util.ts
Normal file
206
apps/sso-core/src/infra/chat-media.util.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
'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() ?? '';
|
||||||
|
}
|
||||||
28
apps/sso-core/src/infra/encryption.service.ts
Normal file
28
apps/sso-core/src/infra/encryption.service.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/sso-core/src/infra/grpc-exception.filter.ts
Normal file
47
apps/sso-core/src/infra/grpc-exception.filter.ts
Normal file
@@ -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<never> {
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/sso-core/src/infra/ldap-client.service.ts
Normal file
68
apps/sso-core/src/infra/ldap-client.service.ts
Normal file
@@ -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<string>('LDAP_AUTH_URL', 'http://ldap-auth:8086').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(config: LdapConnectionConfig, username: string, password: string): Promise<LdapAuthResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/sso-core/src/infra/ldap-config.util.ts
Normal file
67
apps/sso-core/src/infra/ldap-config.util.ts
Normal file
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
166
apps/sso-core/src/infra/minio.service.ts
Normal file
166
apps/sso-core/src/infra/minio.service.ts
Normal file
@@ -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<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||||
|
return new S3Client({
|
||||||
|
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
|
||||||
|
region: this.config.get<string>('MINIO_REGION', 'us-east-1'),
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.config.get<string>('MINIO_ACCESS_KEY', 'minioadmin'),
|
||||||
|
secretAccessKey: this.config.get<string>('MINIO_SECRET_KEY', 'minioadmin')
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const internalEndpoint = this.config.get<string>('MINIO_ENDPOINT', 'localhost:9000');
|
||||||
|
const publicEndpoint = this.config.get<string>('MINIO_PUBLIC_ENDPOINT', internalEndpoint);
|
||||||
|
this.bucket = this.config.get<string>('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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
apps/sso-core/src/infra/notification-publisher.service.ts
Normal file
76
apps/sso-core/src/infra/notification-publisher.service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(NotificationPublisherService.name);
|
||||||
|
private connection: { createChannel: () => Promise<amqp.Channel>; close: () => Promise<void> } | 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<string>('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/');
|
||||||
|
try {
|
||||||
|
this.connection = (await amqp.connect(url)) as {
|
||||||
|
createChannel: () => Promise<amqp.Channel>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/sso-core/src/infra/prisma.service.ts
Normal file
22
apps/sso-core/src/infra/prisma.service.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/sso-core/src/infra/redis.service.ts
Normal file
28
apps/sso-core/src/infra/redis.service.ts
Normal file
@@ -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<string>('REDIS_URL', 'redis://localhost:6379'), {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
lazyConnect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireLock(key: string, ttlMs: number): Promise<boolean> {
|
||||||
|
const result = await this.client.set(key, '1', 'PX', ttlMs, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseLock(key: string): Promise<void> {
|
||||||
|
await this.client.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/sso-core/src/main.ts
Normal file
46
apps/sso-core/src/main.ts
Normal file
@@ -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<MicroserviceOptions>({
|
||||||
|
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<string>('GRPC_URL')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.startAllMicroservices();
|
||||||
|
await app.listen(config.get<number>('HTTP_PORT', 3001));
|
||||||
|
Logger.log('SSO Core запущен', 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
10
apps/sso-core/tsconfig.json
Normal file
10
apps/sso-core/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user