diff --git a/src/app.module.ts b/src/app.module.ts index 37d8e74..696bb75 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,12 +9,11 @@ import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; import { UserModule } from './modules/user'; import { GlobalExceptionFilter } from '@shared/error'; -import { AuthModule } from './modules/auth'; +import { AuthModule } from './auth/auth.module'; import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; -import { MailProcessor } from '@shared/workers'; import { BullModule } from '@nestjs/bullmq'; -import { MailAdapter } from '@shared/adapters/mail'; +import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; import { TeamsModule } from './modules/teams'; import { ProjectsModule } from './modules/projects'; @@ -63,11 +62,7 @@ import { ProjectsModule } from './modules/projects'; ], providers: [ MigrationService, - { - provide: 'IMailPort', - useClass: MailAdapter, - }, - MailProcessor, + MailModule, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts new file mode 100644 index 0000000..e2aac1f --- /dev/null +++ b/src/auth/application/auth.facade.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { + SignInUseCase, + SignUpUseCase, + SignOutUseCase, + SignUpVerifyUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + VerifyResetPasswordUseCase, + ConfirmResetPasswordUseCase, +} from './use-cases'; +import { + PasswordResetConfirmDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from './dtos'; +import type { DeviceMetadata } from '../infrastructure/utils/get-device-meta'; + +@Injectable() +export class AuthFacade { + constructor( + private readonly signInUseCase: SignInUseCase, + private readonly signUpUseCase: SignUpUseCase, + private readonly signOutUseCase: SignOutUseCase, + private readonly signUpVerifyUseCase: SignUpVerifyUseCase, + private readonly refreshTokensUseCase: RefreshTokensUseCase, + private readonly resetPasswordUseCase: ResetPasswordUseCase, + private readonly verifyResetPasswordUseCase: VerifyResetPasswordUseCase, + private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, + ) {} + + async signIn(dto: SignInDto, device: DeviceMetadata) { + return this.signInUseCase.execute(dto, device); + } + + async signUp(dto: SignUpDto) { + return this.signUpUseCase.execute(dto); + } + + async verifySignUp(dto: VerifyDto, device: DeviceMetadata) { + return this.signUpVerifyUseCase.execute(dto, device); + } + + async signOut(userId: string) { + return this.signOutUseCase.execute(userId); + } + + async refreshTokens(token: string, device: DeviceMetadata) { + return this.refreshTokensUseCase.execute(token, device); + } + + async sendResetCode(dto: ResetPasswordDto) { + return this.resetPasswordUseCase.execute(dto); + } + + async verifyResetCode(dto: VerifyResetCodeDto) { + return this.verifyResetPasswordUseCase.execute(dto); + } + + async confirmNewPassword(dto: PasswordResetConfirmDto) { + return this.confirmResetPasswordUseCase.execute(dto); + } +} diff --git a/src/modules/auth/controller/auth.controller.ts b/src/auth/application/controller/auth/controller.ts similarity index 86% rename from src/modules/auth/controller/auth.controller.ts rename to src/auth/application/controller/auth/controller.ts index 0e1ae9c..ec136be 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -1,21 +1,21 @@ -import { ApiBaseController } from '../../../shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, PostRefreshSwagger, PostRegisterSwagger, PostSignUpConfirmSwagger, -} from './auth.swagger'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; +} from './swagger'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { AuthFacade } from '../../auth.facade'; +import { getDeviceMeta } from '@core/auth/infrastructure/utils/get-device-meta'; @ApiBaseController('auth', 'Auth') export class AuthController { - constructor(private readonly facade: AuthService) {} + constructor(private readonly facade: AuthFacade) {} @Post('sign-up') @PostRegisterSwagger() @@ -27,13 +27,13 @@ export class AuthController { @Post('sign-up/confirm') @PostSignUpConfirmSwagger() @HttpCode(201) - async verify( + async verifySignUp( @Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest, @Body() dto: VerifyDto, ) { const meta = getDeviceMeta(req); - const { tokens, ...response } = await this.facade.verify(dto, meta); + const { tokens, ...response } = await this.facade.verifySignUp(dto, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, @@ -84,7 +84,7 @@ export class AuthController { async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const meta = getDeviceMeta(req); const session = req.cookies?.['refresh']; - const { tokens, ...response } = await this.facade.refresh(session, meta); + const { tokens, ...response } = await this.facade.refreshTokens(session, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts new file mode 100644 index 0000000..41ff77a --- /dev/null +++ b/src/auth/application/controller/auth/swagger.ts @@ -0,0 +1,143 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; +import { ActionResponse } from '@shared/dtos'; + +export const PostRegisterSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Регистрация нового пользователя', + description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', + }), + ApiBody({ type: SignUpDto.Output }), + ApiResponse({ + status: 201, + description: 'Пользователь успешно зарегистрирован.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), + ApiConflict('Пользователь с таким email уже существует'), + ); + +export const PostLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вход в систему', + description: + 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', + }), + ApiBody({ type: SignInDto.Output }), + ApiResponse({ + status: 200, + description: 'Успешный вход.', + schema: { + example: { + success: true, + message: false, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiBadRequest('Неверный формат email'), + ApiUnauthorized('Неверный email или пароль'), + ); + +export const PostRefreshSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновление токенов', + description: 'Выдает новую пару Access и Refresh токенов.', + }), + ApiResponse({ + status: 200, + description: 'Токены успешно обновлены.', + schema: { + example: { + success: true, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + message: 'def50200508a1768c7e...', + }, + }, + }), + ApiBadRequest('Ошибка валидации (не передан refresh токен)'), + ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + ); + +export const PostLogoutSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Выход из системы', + description: 'Удаляет текущую сессию пользователя из Redis.', + }), + ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), + ApiUnauthorized(), + ); + +export const PostSignUpConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение регистрации по коду', + description: + 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', + }), + ApiBody({ type: VerifyDto.Output }), + ApiResponse({ + status: 201, + description: 'Аккаунт подтверждён, сессия создана.', + schema: { + example: { + success: true, + message: 'Аккаунт успешно подтвержден', + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), + ApiBadRequest('Срок регистрации истёк или сессия не найдена'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + schema: { + example: [ + { + id: 'clj1xyz990000abc1', + device: 'Chrome on macOS', + ip: '192.168.1.1', + lastActive: '2026-04-11T14:30:00.000Z', + isCurrent: true, + }, + ], + }, + }), + ApiUnauthorized(), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + ); diff --git a/src/auth/application/controller/index.ts b/src/auth/application/controller/index.ts new file mode 100644 index 0000000..c2c6838 --- /dev/null +++ b/src/auth/application/controller/index.ts @@ -0,0 +1,2 @@ +export { AuthController } from './auth/controller'; +export { AuthRecoveryController } from './recovery/controller'; diff --git a/src/modules/auth/controller/recovery.controller.ts b/src/auth/application/controller/recovery/controller.ts similarity index 53% rename from src/modules/auth/controller/recovery.controller.ts rename to src/auth/application/controller/recovery/controller.ts index 25961bd..e274427 100644 --- a/src/modules/auth/controller/recovery.controller.ts +++ b/src/auth/application/controller/recovery/controller.ts @@ -1,32 +1,32 @@ -import { ApiBaseController } from '../../../shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { Body, Post } from '@nestjs/common'; -import { AuthRecoveryService } from '../services'; import { PostPasswordResetConfirmSwagger, PostPasswordResetSwagger, PostPasswordResetVerifySwagger, -} from './auth.swagger'; -import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; +} from './swagger'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../../dtos'; +import { AuthFacade } from '../../auth.facade'; @ApiBaseController('auth', 'Auth Recovery') export class AuthRecoveryController { - constructor(private readonly facade: AuthRecoveryService) {} + constructor(private readonly facade: AuthFacade) {} @Post('password/reset') @PostPasswordResetSwagger() - async resetPasswordRequest(@Body() dto: ResetPasswordDto) { - return this.facade.resetPass(dto); + async sendResetCode(@Body() dto: ResetPasswordDto) { + return this.facade.sendResetCode(dto); } @Post('password/reset/verify') @PostPasswordResetVerifySwagger() async verifyResetCode(@Body() dto: VerifyResetCodeDto) { - return this.facade.verifyResetPassword(dto); + return this.facade.verifyResetCode(dto); } @Post('password/reset/confirm') @PostPasswordResetConfirmSwagger() - async confirmPasswordReset(@Body() dto: PasswordResetConfirmDto) { - return this.facade.confirmResetPass(dto); + async confirmNewPassword(@Body() dto: PasswordResetConfirmDto) { + return this.facade.confirmNewPassword(dto); } } diff --git a/src/modules/auth/controller/auth.swagger.ts b/src/auth/application/controller/recovery/swagger.ts similarity index 63% rename from src/modules/auth/controller/auth.swagger.ts rename to src/auth/application/controller/recovery/swagger.ts index d381d31..3e10cdc 100644 --- a/src/modules/auth/controller/auth.swagger.ts +++ b/src/auth/application/controller/recovery/swagger.ts @@ -2,7 +2,6 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiBadRequest, - ApiConflict, ApiErrorResponse, ApiForbidden, ApiNotFound, @@ -15,107 +14,10 @@ import { Disable2FaDto, PasswordResetConfirmDto, ResetPasswordDto, - SignInDto, - SignUpDto, - VerifyDto, VerifyResetCodeDto, -} from '../dtos'; +} from '../../dtos'; import { ActionResponse } from '@shared/dtos'; -export const PostRegisterSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Регистрация нового пользователя', - description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', - }), - ApiBody({ type: SignUpDto.Output }), - ApiResponse({ - status: 201, - description: 'Пользователь успешно зарегистрирован.', - type: ActionResponse.Output, - }), - ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), - ApiConflict('Пользователь с таким email уже существует'), - ); - -export const PostLoginSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Вход в систему', - description: - 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', - }), - ApiBody({ type: SignInDto.Output }), - ApiResponse({ - status: 200, - description: 'Успешный вход.', - schema: { - example: { - success: true, - message: false, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiBadRequest('Неверный формат email'), - ApiUnauthorized('Неверный email или пароль'), - ); - -export const PostRefreshSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновление токенов', - description: 'Выдает новую пару Access и Refresh токенов.', - }), - ApiResponse({ - status: 200, - description: 'Токены успешно обновлены.', - schema: { - example: { - success: true, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - message: 'def50200508a1768c7e...', - }, - }, - }), - ApiBadRequest('Ошибка валидации (не передан refresh токен)'), - ApiUnauthorized('Refresh токен недействителен, истек или отозван'), - ); - -export const PostLogoutSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Выход из системы', - description: 'Удаляет текущую сессию пользователя из Redis.', - }), - ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), - ApiUnauthorized(), - ); - -export const PostSignUpConfirmSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Подтверждение регистрации по коду', - description: - 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', - }), - ApiBody({ type: VerifyDto.Output }), - ApiResponse({ - status: 201, - description: 'Аккаунт подтверждён, сессия создана.', - schema: { - example: { - success: true, - message: 'Аккаунт успешно подтвержден', - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), - ApiBadRequest('Срок регистрации истёк или сессия не найдена'), - ApiBadRequest('Неверный или истёкший код подтверждения'), - ); - export const PostPasswordResetSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/modules/auth/dtos/2fa.dto.ts b/src/auth/application/dtos/2fa.dto.ts similarity index 100% rename from src/modules/auth/dtos/2fa.dto.ts rename to src/auth/application/dtos/2fa.dto.ts diff --git a/src/modules/auth/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts similarity index 100% rename from src/modules/auth/dtos/auth.dto.ts rename to src/auth/application/dtos/auth.dto.ts diff --git a/src/modules/auth/dtos/index.ts b/src/auth/application/dtos/index.ts similarity index 100% rename from src/modules/auth/dtos/index.ts rename to src/auth/application/dtos/index.ts diff --git a/src/modules/auth/dtos/password.dto.ts b/src/auth/application/dtos/password.dto.ts similarity index 100% rename from src/modules/auth/dtos/password.dto.ts rename to src/auth/application/dtos/password.dto.ts diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts new file mode 100644 index 0000000..b6be75a --- /dev/null +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -0,0 +1,64 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import Redis from 'ioredis'; +import { UpdatePassUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { PasswordResetConfirmDto } from '../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + private readonly updateUserPass: UpdatePassUserCommand, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'RESET_SESSION_NOT_FOUND', + message: + 'Сессия восстановления не найдена или истекла. Начните процесс заново.', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: 'CODE_NOT_VERIFIED', + message: 'Код подтверждения еще не был верифицирован.', + details: [{ target: 'isVerified', value: false }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const hashed = await argon.hash(dto.password); + const isUpdated = await this.updateUserPass.execute(dto.email, hashed); + + if (!isUpdated) { + throw new BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Попробуйте позже.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + await this.redis.del(redisKey); + + return { + success: true, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } +} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts new file mode 100644 index 0000000..97a9482 --- /dev/null +++ b/src/auth/application/use-cases/index.ts @@ -0,0 +1,8 @@ +export { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; +export { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +export { RefreshTokensUseCase } from './refresh-tokens.use-case'; +export { ResetPasswordUseCase } from './reset-password.use-case'; +export { SignUpVerifyUseCase } from './sign-up-verify.use-case'; +export { SignInUseCase } from './sign-in.use-case'; +export { SignOutUseCase } from './sign-out.use-case'; +export { SignUpUseCase } from './sign-up.use-case'; diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts new file mode 100644 index 0000000..32dc367 --- /dev/null +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -0,0 +1,71 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; + +@Injectable() +export class RefreshTokensUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(token: string, metadata: DeviceMetadata) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: 'INVALID_TOKEN', + message: 'Сессия недействительна или истекла', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (!session || session.isRevoked) { + throw new BaseException( + { + code: 'SESSION_REVOKED', + message: 'Ваша сессия была отозвана или завершена', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { user } = await this.findUserCommand.execute({ id: session.userId }); + + if (!user) { + await this.sessionRepo.revoke(session.id); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + await this.sessionRepo.revoke(session.id); + + const newSession = await this.sessionRepo.create({ + userId: user.id, + ...metadata, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); + + return { + tokens: { access, refresh }, + success: true, + message: 'Токены успешно обновлены', + }; + } +} diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts new file mode 100644 index 0000000..82046f8 --- /dev/null +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -0,0 +1,67 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; +import { ResetPasswordEvent } from '../../domain/events'; +import { ResetPasswordDto } from '../dtos'; + +@Injectable() +export class ResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: ResetPasswordDto) { + const entity = await this.findUserCommand.execute({ email: dto.email }); + + if (!entity.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь с таким email не найден', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + const resetPayload = { + email: entity.user.email, + otp: { secret, token }, + isVerified: false, + }; + + await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); + + const event = new ResetPasswordEvent(dto.email, token); + await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts new file mode 100644 index 0000000..30ff23d --- /dev/null +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -0,0 +1,62 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { SignInDto } from '../dtos'; + +@Injectable() +export class SignInUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserCommand.execute({ email: dto.email }); + + if (!entities?.user || !entities?.security) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { security, user } = entities; + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { id } = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, id); + + return { + success: true, + tokens: { + access, + refresh, + }, + message: 'Вы успешно вошли в систему', + }; + } +} diff --git a/src/auth/application/use-cases/sign-out.use-case.ts b/src/auth/application/use-cases/sign-out.use-case.ts new file mode 100644 index 0000000..23d85fc --- /dev/null +++ b/src/auth/application/use-cases/sign-out.use-case.ts @@ -0,0 +1,46 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; + +@Injectable() +export class SignOutUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(token: string) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: 'SESSION_EXPIRED', + message: 'Сессия уже истекла', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (session) { + const isRevoked = await this.sessionRepo.revoke(session.id); + + if (!isRevoked) { + throw new BaseException( + { + code: 'SIGNOUT_FAILED', + message: 'Не удалось завершить сессию на сервере. Попробуйте позже.', + details: [{ target: 'database', message: 'Session revocation failed' }], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + return { success: true, message: 'Успешно вышли из системы!' }; + } +} diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts new file mode 100644 index 0000000..9afb00c --- /dev/null +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -0,0 +1,81 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { CreateUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { VerifyDto } from '../dtos'; + +@Injectable() +export class SignUpVerifyUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly createUserCommand: CreateUserCommand, + ) {} + + async execute(dto: VerifyDto, meta: DeviceMetadata) { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }, + HttpStatus.GONE, + ); + } + + const userData = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + afterTimeStep: 1, + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'OTP code is invalid or expired' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const user = await this.createUserCommand.execute({ + ...userData.user, + password: userData.password, + }); + + const session = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + const { access, refresh } = await this.tokenService.generateTokens(user, session.id); + + await this.redis.del(redisKey); + + return { + success: true, + tokens: { access, refresh }, + message: 'Аккаунт успешно подтвержден', + }; + } +} diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts new file mode 100644 index 0000000..1217fd6 --- /dev/null +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -0,0 +1,86 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { AuthQueues, AuthMailJobs } from '../../domain/enums'; +import { RegisterCodeEvent } from '../../domain/events'; +import { SignUpDto } from '../dtos'; + +@Injectable() +export class SignUpUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: SignUpDto) { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (cachedData) { + throw new BaseException( + { + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const isExists = await this.findUserCommand.execute({ email: dto.email }); + + if (isExists) { + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + }); + + const data = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/verify-reset-password.use-case.ts b/src/auth/application/use-cases/verify-reset-password.use-case.ts new file mode 100644 index 0000000..842ae33 --- /dev/null +++ b/src/auth/application/use-cases/verify-reset-password.use-case.ts @@ -0,0 +1,63 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { BaseException } from '@shared/error'; +import { VerifyResetCodeDto } from '../dtos'; + +@Injectable() +export class VerifyResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(dto: VerifyResetCodeDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'RESET_SESSION_EXPIRED', + message: + 'Время подтверждения истекло или запрос не найден. Запросите код снова.', + }, + HttpStatus.GONE, + ); + } + + const resetSession = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: resetSession.otp.secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: 'INVALID_VERIFICATION_CODE', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.redis.set( + redisKey, + JSON.stringify({ ...resetSession, isVerified: true }), + 'EX', + 600, + ); + + return { + success: true, + message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + } +} diff --git a/src/modules/auth/auth.module.ts b/src/auth/auth.module.ts similarity index 66% rename from src/modules/auth/auth.module.ts rename to src/auth/auth.module.ts index 1ea71d4..98ac4ac 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,16 +1,42 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { UserModule } from '../user'; -import { AuthController, AuthRecoveryController } from './controller'; -import { AuthRecoveryService, AuthService, TokenService } from './services'; -import { JwtModule } from '@nestjs/jwt'; -import { ConfigService } from '@nestjs/config'; -import { RedisModule } from '@nestjs-modules/ioredis'; -import { SessionRepository } from './repository'; -import { BearerStrategy, CookieStrategy } from './strategies'; -import { BullModule } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { BullModule } from '@nestjs/bullmq'; +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { UserModule } from '@core/modules/user'; +import { AuthController, AuthRecoveryController } from './application/controller'; +import { AuthFacade } from './application/auth.facade'; +import { + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +} from './application/use-cases'; +import { AuthQueues } from './domain/enums'; +import { SessionRepository } from './infrastructure/persistence/repositories'; +import { TokenService } from './infrastructure/security'; +import { BearerStrategy, CookieStrategy } from './infrastructure/strategies'; +import { MailProcessor } from './infrastructure/workers'; +import { MailAdapter } from '@shared/adapters/mail'; + +const USE_CASES = [ + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +]; + +const WORKERS = [MailProcessor]; const REPOSITORY = { provide: 'ISessionRepository', @@ -61,22 +87,28 @@ const REPOSITORY = { }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, adapter: BullMQAdapter, }), forwardRef(() => UserModule), ], controllers: [AuthController, AuthRecoveryController], providers: [ - REPOSITORY, - AuthService, + // TOOD: FIX PROVIDER + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ...WORKERS, TokenService, CookieStrategy, BearerStrategy, - AuthRecoveryService, + REPOSITORY, + ...USE_CASES, + AuthFacade, ], exports: [], }) diff --git a/src/auth/domain/domain/.gitkeep b/src/auth/domain/domain/.gitkeep new file mode 100644 index 0000000..42e6429 --- /dev/null +++ b/src/auth/domain/domain/.gitkeep @@ -0,0 +1 @@ +# feature added entity class \ No newline at end of file diff --git a/src/auth/domain/enums/index.ts b/src/auth/domain/enums/index.ts new file mode 100644 index 0000000..a2f814f --- /dev/null +++ b/src/auth/domain/enums/index.ts @@ -0,0 +1 @@ +export { AuthMailJobs, AuthQueues } from './mail-jobs.enum'; diff --git a/src/auth/domain/enums/mail-jobs.enum.ts b/src/auth/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..e9ff5ab --- /dev/null +++ b/src/auth/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,9 @@ +export enum AuthQueues { + AUTH_MAIL = 'AUTH_MAIL_QUEUE', +} + +export enum AuthMailJobs { + SEND_REGISTER_CODE = 'AUTH_SEND_REGISTER_CODE', + SEND_RESET_PASSWORD = 'AUTH_SEND_RESET_PASSWORD', + SEND_CHANGE_EMAIL = 'AUTH_SEND_CHANGE_EMAIL', +} diff --git a/src/auth/domain/events/index.ts b/src/auth/domain/events/index.ts new file mode 100644 index 0000000..61a6360 --- /dev/null +++ b/src/auth/domain/events/index.ts @@ -0,0 +1,2 @@ +export { RegisterCodeEvent } from './register-code.event'; +export { ResetPasswordEvent } from './reset-password.event'; diff --git a/src/shared/workers/events/register-code.event.ts b/src/auth/domain/events/register-code.event.ts similarity index 100% rename from src/shared/workers/events/register-code.event.ts rename to src/auth/domain/events/register-code.event.ts diff --git a/src/shared/workers/events/reset-password.event.ts b/src/auth/domain/events/reset-password.event.ts similarity index 100% rename from src/shared/workers/events/reset-password.event.ts rename to src/auth/domain/events/reset-password.event.ts diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts new file mode 100644 index 0000000..298c188 --- /dev/null +++ b/src/auth/domain/repository/index.ts @@ -0,0 +1 @@ +export * from './session.repository.interface'; diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/auth/domain/repository/session.repository.interface.ts similarity index 85% rename from src/modules/auth/repository/session.repository.interface.ts rename to src/auth/domain/repository/session.repository.interface.ts index cde6762..e83a682 100644 --- a/src/modules/auth/repository/session.repository.interface.ts +++ b/src/auth/domain/repository/session.repository.interface.ts @@ -1,4 +1,4 @@ -import { sessions } from '../entities'; +import { sessions } from '../../infrastructure/persistence/models/session.model'; export type SessionInsert = typeof sessions.$inferInsert; export type SessionSelect = typeof sessions.$inferSelect; diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..9b52ede --- /dev/null +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -0,0 +1 @@ +export { sessions } from './session.model'; diff --git a/src/modules/auth/entities/session.entity.ts b/src/auth/infrastructure/persistence/models/session.model.ts similarity index 92% rename from src/modules/auth/entities/session.entity.ts rename to src/auth/infrastructure/persistence/models/session.model.ts index e3a1492..db788ae 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -1,8 +1,7 @@ import { createId } from '@paralleldrive/cuid2'; import { text, timestamp, varchar } from 'drizzle-orm/pg-core'; import { boolean } from 'drizzle-orm/pg-core'; -import { baseSchema } from '@shared/entities'; -import { users } from '../../user/entities'; +import { baseSchema, users } from '@shared/entities'; export const sessions = baseSchema.table('sessions', { id: text('id') diff --git a/src/modules/auth/repository/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts similarity index 54% rename from src/modules/auth/repository/index.ts rename to src/auth/infrastructure/persistence/repositories/index.ts index f1ead53..d223cdb 100644 --- a/src/modules/auth/repository/index.ts +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -1,2 +1 @@ -export * from './session.repository.interface'; export { SessionRepository } from './session.repository'; diff --git a/src/modules/auth/repository/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts similarity index 93% rename from src/modules/auth/repository/session.repository.ts rename to src/auth/infrastructure/persistence/repositories/session.repository.ts index 43510a0..709593a 100644 --- a/src/modules/auth/repository/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; -import * as schema from '../entities'; +import * as schema from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { ISessionRepository, type SessionInsert } from './session.repository.interface'; +import { ISessionRepository, type SessionInsert } from '../../../domain/repository'; @Injectable() export class SessionRepository implements ISessionRepository { diff --git a/src/auth/infrastructure/security/index.ts b/src/auth/infrastructure/security/index.ts new file mode 100644 index 0000000..0b27e01 --- /dev/null +++ b/src/auth/infrastructure/security/index.ts @@ -0,0 +1 @@ +export { TokenService } from './token.service'; diff --git a/src/modules/auth/services/token.service.ts b/src/auth/infrastructure/security/token.service.ts similarity index 100% rename from src/modules/auth/services/token.service.ts rename to src/auth/infrastructure/security/token.service.ts diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts similarity index 100% rename from src/modules/auth/strategies/bearer.strategy.ts rename to src/auth/infrastructure/strategies/bearer.strategy.ts diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts similarity index 100% rename from src/modules/auth/strategies/cookie.strategy.ts rename to src/auth/infrastructure/strategies/cookie.strategy.ts diff --git a/src/modules/auth/strategies/index.ts b/src/auth/infrastructure/strategies/index.ts similarity index 100% rename from src/modules/auth/strategies/index.ts rename to src/auth/infrastructure/strategies/index.ts diff --git a/src/modules/auth/helpers/get-device-meta.ts b/src/auth/infrastructure/utils/get-device-meta.ts similarity index 100% rename from src/modules/auth/helpers/get-device-meta.ts rename to src/auth/infrastructure/utils/get-device-meta.ts diff --git a/src/auth/infrastructure/workers/index.ts b/src/auth/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/auth/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/auth/infrastructure/workers/mail.processor.ts b/src/auth/infrastructure/workers/mail.processor.ts new file mode 100644 index 0000000..3e4a926 --- /dev/null +++ b/src/auth/infrastructure/workers/mail.processor.ts @@ -0,0 +1,72 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { IMailPort } from '@shared/adapters/mail'; +import { Inject } from '@nestjs/common'; +import { RegisterCodeEvent, ResetPasswordEvent } from '../../domain/events'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; + +@Processor(AuthQueues.AUTH_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise; + async process(job: Job): Promise; + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case AuthMailJobs.SEND_REGISTER_CODE: + await this.sendRegisterCode(job); + break; + case AuthMailJobs.SEND_RESET_PASSWORD: + await this.sendResetPassCode(job); + break; + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private sendRegisterCode = async (job: Job) => { + const { email, name, otp } = job.data; + + await job.log(`Sending registration code to: ${email}`); + await job.updateProgress(20); + + await this.mailAdapter.sendRegistrationCode(email, name, otp); + + await job.log(`Successfully sent to ${email}`); + await job.updateProgress(100); + }; + + private sendResetPassCode = async (job: Job) => { + const { email, otp } = job.data; + + await job.log(`Sending password reset to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendResetPasswordCode(email, otp); + + await job.log(`Reset link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/modules/auth/controller/index.ts b/src/modules/auth/controller/index.ts deleted file mode 100644 index c9ed49f..0000000 --- a/src/modules/auth/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AuthController } from './auth.controller'; -export { AuthRecoveryController } from './recovery.controller'; diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts deleted file mode 100644 index 5330080..0000000 --- a/src/modules/auth/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { sessions } from './session.entity'; diff --git a/src/modules/auth/helpers/index.ts b/src/modules/auth/helpers/index.ts deleted file mode 100644 index 1740a4d..0000000 --- a/src/modules/auth/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type DeviceMetadata, getDeviceMeta } from './get-device-meta'; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts deleted file mode 100644 index faa5c33..0000000 --- a/src/modules/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthModule } from './auth.module'; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts deleted file mode 100644 index 488ace6..0000000 --- a/src/modules/auth/services/auth.service.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; -import { generate, generateSecret, verify as verifyOTP } from 'otplib'; -import * as argon from 'argon2'; -import { CreateUserCommand, FindOneUserCommand } from '../../user'; -import { TokenService } from './token.service'; -import { ISessionRepository } from '../repository'; -import { DeviceMetadata } from '../helpers'; -import { InjectQueue } from '@nestjs/bullmq'; -import { Queues, RegisterCodeEvent } from '@shared/workers'; -import type { Queue } from 'bullmq'; -import { MailJobs } from '@shared/workers/enum'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class AuthService { - constructor( - @InjectRedis() - private readonly redis: Redis, - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly tokenService: TokenService, - private readonly findUserCommand: FindOneUserCommand, - private readonly createUserCommand: CreateUserCommand, - ) {} - - public signUp = async (dto: SignUpDto) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_IN_PROGRESS', - message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', - details: [{ target: 'email', message: 'Verification code already sent' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const isExists = await this.findUserCommand.execute({ email: dto.email }); - - if (isExists) { - throw new BaseException( - { - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.CONFLICT, - ); - } - - const hashPass = await argon.hash(dto.password); - - const secret = generateSecret(); - const token = await generate({ - secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - }); - - const data = { - user: dto, - password: hashPass, - otp: { token, secret }, - }; - - await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); - - const event = new RegisterCodeEvent(dto.email, dto.firstName, token); - await this.mailQueue.add(MailJobs.SEND_REGISTER_CODE, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код подтверждения отправлен на вашу почту', - }; - }; - - public verify = async (dto: VerifyDto, meta: DeviceMetadata) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }, - HttpStatus.GONE, - ); - } - - const userData = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: userData.otp.secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - afterTimeStep: 1, - }); - - if (!verifyResult.valid) { - throw new BaseException( - { - code: 'INVALID_OTP', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'OTP code is invalid or expired' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const user = await this.createUserCommand.execute({ - ...userData.user, - password: userData.password, - }); - - const session = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - const { access, refresh } = await this.tokenService.generateTokens(user, session.id); - - await this.redis.del(redisKey); - - return { - success: true, - tokens: { access, refresh }, - message: 'Аккаунт успешно подтвержден', - }; - }; - - public signIn = async (dto: SignInDto, meta: DeviceMetadata) => { - const entities = await this.findUserCommand.execute({ email: dto.email }); - - if (!entities?.user || !entities?.security) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { security, user } = entities; - const isPasswordValid = await argon.verify(security.passwordHash, dto.password); - - if (!isPasswordValid) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { id } = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, id); - - return { - success: true, - tokens: { - access, - refresh, - }, - message: 'Вы успешно вошли в систему', - }; - }; - - public refresh = async (token: string, metadata: DeviceMetadata) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'INVALID_TOKEN', - message: 'Сессия недействительна или истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - if (!session || session.isRevoked) { - throw new BaseException( - { - code: 'SESSION_REVOKED', - message: 'Ваша сессия была отозвана или завершена', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { user } = await this.findUserCommand.execute({ id: session.userId }); - - if (!user) { - await this.sessionRepo.revoke(session.id); - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - await this.sessionRepo.revoke(session.id); - - const newSession = await this.sessionRepo.create({ - userId: user.id, - ...metadata, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); - - return { - tokens: { access, refresh }, - success: true, - message: 'Токены успешно обновлены', - }; - }; - - public signOut = async (token: string) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'SESSION_EXPIRED', - message: 'Сессия уже истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - if (session) { - const isRevoked = await this.sessionRepo.revoke(session.id); - - if (!isRevoked) { - throw new BaseException( - { - code: 'SIGNOUT_FAILED', - message: 'Не удалось завершить сессию на сервере. Попробуйте позже.', - details: [{ target: 'database', message: 'Session revocation failed' }], - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - } - - return { success: true, message: 'Успешно вышли из системы!' }; - }; -} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts deleted file mode 100644 index efc6350..0000000 --- a/src/modules/auth/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthService } from './auth.service'; -export { TokenService } from './token.service'; -export { AuthRecoveryService } from './recovery.service'; diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts deleted file mode 100644 index c579cc9..0000000 --- a/src/modules/auth/services/recovery.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; -import { generate, generateSecret, verify as verifyOTP } from 'otplib'; -import * as argon from 'argon2'; -import { FindOneUserCommand, UpdatePassUserCommand } from '../../user'; -import { InjectQueue } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; -import type { Queue } from 'bullmq'; -import { MailJobs } from '@shared/workers/enum'; -import { ResetPasswordEvent } from '@shared/workers/events'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class AuthRecoveryService { - constructor( - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly findUserCommand: FindOneUserCommand, - private readonly updateUserPass: UpdatePassUserCommand, - ) {} - - public resetPass = async (dto: ResetPasswordDto) => { - const entity = await this.findUserCommand.execute({ email: dto.email }); - - if (!entity.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.NOT_FOUND, - ); - } - - const secret = generateSecret(); - const token = await generate({ - secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - const resetPayload = { - email: entity.user.email, - otp: { secret, token }, - isVerified: false, - }; - - await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); - - const event = new ResetPasswordEvent(dto.email, token); - await this.mailQueue.add(MailJobs.SEND_RESET_PASSWORD, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код для восстановления пароля отправлен на вашу почту', - }; - }; - - public verifyResetPassword = async (dto: VerifyResetCodeDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_EXPIRED', - message: - 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }, - HttpStatus.GONE, - ); - } - - const resetSession = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: resetSession.otp.secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - if (!verifyResult.valid) { - throw new BaseException( - { - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'The provided OTP is incorrect' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - await this.redis.set( - redisKey, - JSON.stringify({ ...resetSession, isVerified: true }), - 'EX', - 600, - ); - - return { - success: true, - message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', - }; - }; - - public confirmResetPass = async (dto: PasswordResetConfirmDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_NOT_FOUND', - message: - 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - const resetSession = JSON.parse(cachedData); - - if (!resetSession.isVerified) { - throw new BaseException( - { - code: 'CODE_NOT_VERIFIED', - message: 'Код подтверждения еще не был верифицирован.', - details: [{ target: 'isVerified', value: false }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const hashed = await argon.hash(dto.password); - const isUpdated = await this.updateUserPass.execute(dto.email, hashed); - - if (!isUpdated) { - throw new BaseException( - { - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - await this.redis.del(redisKey); - - return { - success: true, - message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', - }; - }; -} diff --git a/src/shared/adapters/mail/index.ts b/src/shared/adapters/mail/index.ts index f798bbb..e132652 100644 --- a/src/shared/adapters/mail/index.ts +++ b/src/shared/adapters/mail/index.ts @@ -1,2 +1,3 @@ export { MailAdapter } from './adapter'; export { IMailPort } from './port'; +export { MailModule } from './module'; diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts new file mode 100644 index 0000000..50174b7 --- /dev/null +++ b/src/shared/adapters/mail/module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { MailAdapter } from './adapter'; +import { MailProcessor } from '@shared/workers'; + +@Global() +@Module({ + providers: [ + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + MailProcessor, + ], + exports: ['IMailPort'], +}) +export class MailModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 676f897..b618226 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; export * from '../../modules/user/entities'; -export * from '../../modules/auth/entities'; +export * from '../../auth/infrastructure/persistence/models'; export * from '../../modules/teams/entities'; export * from '../../modules/projects/entities'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts index 863d67a..433d08a 100644 --- a/src/shared/workers/enum.ts +++ b/src/shared/workers/enum.ts @@ -3,8 +3,5 @@ export enum Queues { } export enum MailJobs { - SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', - SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', - SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', } diff --git a/src/shared/workers/events/index.ts b/src/shared/workers/events/index.ts index 6430cb9..f0cfd4e 100644 --- a/src/shared/workers/events/index.ts +++ b/src/shared/workers/events/index.ts @@ -1,3 +1 @@ -export { RegisterCodeEvent } from './register-code.event'; -export { ResetPasswordEvent } from './reset-password.event'; export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts index 2111275..c14cbc2 100644 --- a/src/shared/workers/index.ts +++ b/src/shared/workers/index.ts @@ -1,3 +1,2 @@ export { MailJobs, Queues } from './enum'; -export { RegisterCodeEvent } from './events'; export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts index 3487606..3fe4d34 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/shared/workers/mail/worker.ts @@ -3,7 +3,7 @@ import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; +import { TeamInvitationEvent } from '../events'; @Processor(Queues.MAIL) export class MailProcessor extends WorkerHost { @@ -14,20 +14,12 @@ export class MailProcessor extends WorkerHost { super(); } - async process(job: Job): Promise; - async process(job: Job): Promise; async process(job: Job): Promise; async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { switch (job.name) { - case MailJobs.SEND_REGISTER_CODE: - await this.sendRegisterCode(job); - break; - case MailJobs.SEND_RESET_PASSWORD: - await this.sendResetPassCode(job); - break; case MailJobs.SEND_TEAM_INVITATION: await this.sendTeamInvitation(job); break; @@ -50,30 +42,6 @@ export class MailProcessor extends WorkerHost { } } - private sendRegisterCode = async (job: Job) => { - const { email, name, otp } = job.data; - - await job.log(`Sending registration code to: ${email}`); - await job.updateProgress(20); - - await this.mailAdapter.sendRegistrationCode(email, name, otp); - - await job.log(`Successfully sent to ${email}`); - await job.updateProgress(100); - }; - - private sendResetPassCode = async (job: Job) => { - const { email, otp } = job.data; - - await job.log(`Sending password reset to: ${email}`); - await job.updateProgress(30); - - await this.mailAdapter.sendResetPasswordCode(email, otp); - - await job.log(`Reset link delivered to ${email}`); - await job.updateProgress(100); - }; - private sendTeamInvitation = async (job: Job) => { const { email, teamName, inviteUrl } = job.data;