fix sso core folder

This commit is contained in:
lendry
2026-06-24 14:45:45 +03:00
parent 995adeedd4
commit d2bbf35d40
45 changed files with 6592 additions and 1 deletions

View 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('Нет доступа к документам пользователя');
}
}
}

View 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()
};
}
}

View 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: {}
});
}
}
}
}

View 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('Пользователь не найден');
}
}
}

View 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()
};
}
}

View 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()
};
}
}

View 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';
}
}

View 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
};
}
}

View 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()
};
}
}

View 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;
}

View 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()
};
}
}

View 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('Нет доступа к чату');
}
}
}

View 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()
};
}
}

View 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 };
}
}

View 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 };
}
}

View 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();
}
}

View 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
};
}
}

View 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('Пользователь не найден');
}
}
}

View 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' }
});
}
}

View 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
}
});
}
}

View 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 };
}
}

View 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) }
});
}
}
}