Reference
Auth Service - Реализация
Детальная реализация Auth Service
Auth Service - Реализация
Обзор
Auth Service — микросервис на NestJS для управления пользователями, KYC, лимитами и санкциями. Использует Connect RPC для gRPC API и Kafka для событий.
Структура проекта
apps/services/auth/src/
├── app/
│ ├── app.controller.ts
│ └── app.service.ts
├── app.module.ts
├── config/
│ └── config.module.ts
├── domains/
│ ├── authz/ # Авторизация
│ │ ├── application/
│ │ ├── domain/
│ │ └── presentation/
│ ├── gateway/ # Connect RPC Gateway
│ │ └── identity-grpc.gateway.ts
│ └── identity/ # Домен пользователя
│ ├── application/
│ │ └── use-cases/
│ ├── domain/
│ └── presentation/
├── infrastructure/
│ ├── database/
│ ├── kafka/
│ └── logging/
├── main.ts
└── shared/Модули
AppModule
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleModule } from '@maniton/nestjs-common/database';
import { KafkaModule } from '@maniton/nestjs-common/kafka';
import { LoggingModule } from '@maniton/nestjs-common/logging';
import { IdentityModule } from './domains/identity/identity.module';
import { AuthZModule } from './domains/authz/authz.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
DrizzleModule.forRoot({
url: process.env.DATABASE_URL,
}),
KafkaModule.forRoot({
brokers: process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'],
clientId: 'auth-service',
}),
LoggingModule.forRoot({
level: process.env.LOG_LEVEL || 'info',
}),
IdentityModule,
AuthZModule,
],
controllers: [],
providers: [],
})
export class AppModule {}Use Cases
GetUserProfileUseCase
Получение профиля пользователя.
// domains/identity/application/use-cases/get-user-profile.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { UserProfile } from '@maniton/contracts/gen/ts/maniton/identity/v1/identity_pb';
@Injectable()
export class GetUserProfileUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(userId: string): Promise<UserProfile> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
return {
userId: user.id,
email: user.email,
kycStatus: user.kycStatus,
isAdmin: user.isAdmin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
}SubmitKycUseCase
Отправка KYC заявки.
// domains/identity/application/use-cases/submit-kyc.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { KycRepository } from '../../domain/repositories/kyc.repository';
import { SanctionsService } from '../../domain/services/sanctions.service';
import { KycStatus } from '@maniton/contracts/gen/ts/maniton/common/v1/common_pb';
@Injectable()
export class SubmitKycUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly kycRepository: KycRepository,
private readonly sanctionsService: SanctionsService,
) {}
async execute(userId: string, payload: any): Promise<void> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
if (user.kycStatus !== KycStatus.NONE) {
throw new Error('KYC already submitted');
}
// Проверка санкций
const sanctionsCheck = await this.sanctionsService.checkSanctions(userId, payload);
if (!sanctionsCheck.allowed) {
throw new Error('Sanctions check failed');
}
// Создание KYC заявки
await this.kycRepository.create({
userId,
status: KycStatus.PENDING,
payload,
submittedAt: new Date(),
});
// Обновление статуса пользователя
await this.userRepository.update(userId, {
kycStatus: KycStatus.PENDING,
});
}
}CheckLimitsUseCase
Проверка лимитов пользователя.
// domains/identity/application/use-cases/check-limits.use-case.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository';
import { LimitRepository } from '../../domain/repositories/limit.repository';
import { OperationType, LimitType } from '@maniton/contracts/gen/ts/maniton/common/v1/common_pb';
@Injectable()
export class CheckLimitsUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly limitRepository: LimitRepository,
) {}
async execute(
userId: string,
limitType: LimitType,
amount: { amount: string; currencyCode: string },
operationType: OperationType,
): Promise<{ allowed: boolean; reason?: string }> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
// Проверка KYC статуса
if (user.kycStatus < KycStatus.STANDARD) {
return { allowed: false, reason: 'KYC level too low' };
}
// Получение лимитов
const limits = await this.limitRepository.findByUserIdAndType(userId, limitType);
if (!limits) {
return { allowed: false, reason: 'No limits configured' };
}
// Проверка лимитов
const amountValue = Number(amount.amount);
if (amountValue > limits.dailyLimit) {
return { allowed: false, reason: 'Daily limit exceeded' };
}
if (amountValue > limits.monthlyLimit) {
return { allowed: false, reason: 'Monthly limit exceeded' };
}
return { allowed: true };
}
}Connect RPC Gateway
IdentityGrpcGateway
// domains/identity/presentation/identity-grpc.gateway.ts
import { ConnectGateway, ConnectRouter } from '@maniton/nestjs-common/connect';
import { IdentityService } from '@maniton/contracts/gen/ts/maniton/identity/v1/identity_pb';
import type { IdentityUseCases } from './identity-grpc.gateway';
export class IdentityGrpcGateway extends ConnectGateway {
protected readonly options = {
requestPathPrefix: '/auth-service',
};
protected registerRoutes(router: ConnectRouter): void {
router.service(IdentityService, {
getUserProfile: (req) => this.useCases.getUserProfile.execute(req.userId),
listUsers: (req) => this.useCases.listUsers.execute(req),
submitKyc: (req) => this.useCases.submitKyc.execute(req.userId, req.payload),
reviewKyc: (req) => this.useCases.reviewKyc.execute(req.kycId, req.approved),
blockUser: (req) => this.useCases.blockUser.execute(req.userId, req.reason),
unblockUser: (req) => this.useCases.unblockUser.execute(req.userId),
getUserLimits: (req) => this.useCases.getUserLimits.execute(req.userId),
checkLimits: (req) => this.useCases.checkLimits.execute(
req.userId,
req.limitType,
req.amount,
req.operationType
)),
checkSanctions: (req) => this.useCases.checkSanctions.execute(req.userId, req.payload),
toggleAdminRole: (req) => this.useCases.toggleAdminRole.execute(req.userId),
setKycLevel: (req) => this.useCases.setKycLevel.execute(req.userId, req.kycStatus),
});
}
}Репозитории
UserRepository
// domains/identity/domain/repositories/user.repository.ts
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '@maniton/nestjs-common/database';
import { users } from '@maniton/contracts/gen/drizzle/schema';
import { eq } from 'drizzle-orm';
@Injectable()
export class UserRepository {
constructor(private readonly drizzle: DrizzleService) {}
async findById(id: string) {
const [user] = await this.drizzle.db.select().from(users).where(eq(users.id, id)).limit(1);
return user;
}
async findByEmail(email: string) {
const [user] = await this.drizzle.db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return user;
}
async create(data: typeof users.$inferInsert) {
const [user] = await this.drizzle.db.insert(users).values(data).returning();
return user;
}
async update(id: string, data: Partial<typeof users.$inferInsert>) {
const [user] = await this.drizzle.db
.update(users)
.set(data)
.where(eq(users.id, id))
.returning();
return user;
}
async list(pagination?: { limit?: number; offset?: number }) {
return this.drizzle.db.select().from(users).limit(pagination?.limit).offset(pagination?.offset);
}
}Сервисы
SanctionsService
// domains/identity/domain/services/sanctions.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SanctionsService {
constructor(private readonly config: ConfigService) {}
async checkSanctions(
userId: string,
payload: any,
): Promise<{ allowed: boolean; reason?: string }> {
// Интеграция с внешним сервисом проверки санкций
// Требуется подключение к Rosfin, OFAC, EU Sanctions List
// Временно возвращаем разрешено для разработки
const apiKey = this.config.get('SANCTIONS_API_KEY');
if (!apiKey) {
// В development режиме пропускаем все проверки
return { allowed: true };
}
// Проверка по внешнему API
const response = await fetch('https://sanctions-api.example.com/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
userId,
...payload,
}),
});
const result = await response.json();
return {
allowed: result.allowed,
reason: result.reason,
};
}
}События Kafka
Публикация событий
// infrastructure/kafka/event-publisher.service.ts
import { Injectable } from '@nestjs/common';
import { ProducerService } from '@maniton/nestjs-common/kafka';
import { create, toBinary } from '@bufbuild/protobuf';
import {
CloudEventSchema,
EventEnvelopeSchema,
} from '@maniton/contracts/gen/ts/maniton/events/v1/envelope_pb';
import { anyPack } from '@maniton/protobuf/wkt';
@Injectable()
export class EventPublisherService {
constructor(private readonly producer: ProducerService) {}
async publish(topic: string, eventType: string, payload: any, context: any) {
const cloudEvent = create(CloudEventSchema, {
id: crypto.randomUUID(),
source: 'auth-service',
specVersion: '1.0',
type: eventType,
dataContentType: 'application/protobuf',
data: {
case: 'protoData',
value: anyPack(eventType, payload),
},
});
const envelope = create(EventEnvelopeSchema, {
event: cloudEvent,
context,
topic,
schemaSubject: eventType,
});
const bytes = toBinary(EventEnvelopeSchema, envelope);
await this.producer.send(topic, bytes);
}
}Обработка событий
// infrastructure/kafka/event-handler.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConsumerService } from '@maniton/nestjs-common/kafka';
import { fromBinary, anyIs, anyUnpack } from '@bufbuild/protobuf';
import { EventEnvelopeSchema } from '@maniton/contracts/gen/ts/maniton/events/v1/envelope_pb';
@Injectable()
export class EventHandlerService implements OnModuleInit {
constructor(private readonly consumer: ConsumerService) {}
async onModuleInit() {
await this.consumer.subscribe({
topic: 'maniton.identity.events.v1',
groupId: 'auth-service',
onMessage: async ({ value }) => {
const envelope = fromBinary(EventEnvelopeSchema, value);
const any =
envelope.event?.data?.case === 'protoData' ? envelope.event.data.value : undefined;
if (any) {
await this.handleEvent(envelope.event.type, any);
}
},
});
}
private async handleEvent(type: string, any: any) {
switch (type) {
case 'maniton.identity.v1.UserCreatedEvent':
await this.handleUserCreated(anyUnpack(any));
break;
case 'maniton.identity.v1.UserBlockedEvent':
await this.handleUserBlocked(anyUnpack(any));
break;
case 'maniton.identity.v1.UserUnblockedEvent':
await this.handleUserUnblocked(anyUnpack(any));
break;
}
}
private async handleUserCreated(event: any) {
// Обработка события
}
private async handleUserBlocked(event: any) {
// Обработка события
}
private async handleUserUnblocked(event: any) {
// Обработка события
}
}Конфигурация
ConfigModule
// config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
validationSchema: {
DATABASE_URL: {
type: 'string',
required: true,
},
KAFKA_BROKERS: {
type: 'string',
required: true,
},
LOG_LEVEL: {
type: 'string',
default: 'info',
},
SANCTIONS_API_KEY: {
type: 'string',
required: false,
},
},
}),
],
exports: [NestConfigModule],
})
export class ConfigModule {}Тестирование
Unit Tests
// domains/identity/application/use-cases/get-user-profile.use-case.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { GetUserProfileUseCase } from './get-user-profile.use-case';
import { UserRepository } from '../../domain/repositories/user.repository';
describe('GetUserProfileUseCase', () => {
let useCase: GetUserProfileUseCase;
let userRepository: jest.Mocked<UserRepository>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GetUserProfileUseCase,
{
provide: UserRepository,
useValue: {
findById: jest.fn(),
},
},
],
}).compile();
useCase = module.get(GetUserProfileUseCase);
userRepository = module.get(UserRepository);
});
it('should return user profile', async () => {
const user = {
id: 'user-123',
email: 'test@example.com',
kycStatus: 'NONE',
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
};
userRepository.findById.mockResolvedValue(user);
const result = await useCase.execute('user-123');
expect(result).toEqual({
userId: user.id,
email: user.email,
kycStatus: user.kycStatus,
isAdmin: user.isAdmin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
});
});
it('should throw error if user not found', async () => {
userRepository.findById.mockResolvedValue(null);
await expect(useCase.execute('user-123')).rejects.toThrow('User not found');
});
});Мониторинг
Метрики
// infrastructure/metrics/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Counter, Histogram, Gauge } from 'prom-client';
@Injectable()
export class MetricsService {
private readonly operationsTotal = new Counter({
name: 'auth_operations_total',
help: 'Total number of auth operations',
labelNames: ['operation', 'status'],
});
private readonly operationDuration = new Histogram({
name: 'auth_operation_duration_seconds',
help: 'Time spent on auth operations',
labelNames: ['operation'],
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60],
});
private readonly activeUsers = new Gauge({
name: 'auth_active_users',
help: 'Number of active users',
});
incrementOperation(operation: string, status: string) {
this.operationsTotal.inc({ operation, status });
}
recordOperationDuration(operation: string, duration: number) {
this.operationDuration.observe({ operation }, duration);
}
setActiveUsers(count: number) {
this.activeUsers.set(count);
}
}Логи
// infrastructure/logging/logger.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoggerService {
private readonly context = 'AuthService';
info(message: string, meta?: Record<string, any>) {
console.log(`[${this.context}] [INFO] ${message}`, meta);
}
error(message: string, error?: Error, meta?: Record<string, any>) {
console.error(`[${this.context}] [ERROR] ${message}`, error, meta);
}
warn(message: string, meta?: Record<string, any>) {
console.warn(`[${this.context}] [WARN] ${message}`, meta);
}
debug(message: string, meta?: Record<string, any>) {
console.debug(`[${this.context}] [DEBUG] ${message}`, meta);
}
}Troubleshooting
Проблема: Gateway не отвечает
Решение:
- Проверьте, что
requestPathPrefixсоответствует Ingress path - Проверьте, что
enableHttp: falseв конфигурации - Проверьте логи на "Initializing Connect RPC middleware at /prefix"
Проблема: Kafka события не публикуются
Решение:
- Проверьте, что Kafka брокеры доступны
- Проверьте, что topic существует
- Проверьте логи EventPublisherService
Проблема: База данных не подключается
Решение:
- Проверьте
DATABASE_URLв переменных окружения - Проверьте, что PostgreSQL запущен
- Проверьте логи DrizzleModule