diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts index fb1fb0e..a3c9d83 100644 --- a/src/auth/application/auth.facade.ts +++ b/src/auth/application/auth.facade.ts @@ -13,17 +13,19 @@ import { DisconnectProviderUseCase, GetConnectedProvidersQuery, GetEnabledProvidersQuery, + ResendCodeUseCase, } from './use-cases'; import { OAuthResponse, PasswordResetConfirmDto, + ResendCodeDto, ResetPasswordDto, SignInDto, SignUpDto, VerifyDto, VerifyResetCodeDto, } from './dtos'; -import type { DeviceMetadata } from '../infrastructure/utils/get-device-meta'; +import type { DeviceMetadata } from '../infrastructure/utils'; @Injectable() export class AuthFacade { @@ -41,6 +43,7 @@ export class AuthFacade { private readonly disconnectProviderUseCase: DisconnectProviderUseCase, private readonly getConnectedProvidersQuery: GetConnectedProvidersQuery, private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, + private readonly resendCodeUseCase: ResendCodeUseCase, ) {} async signIn(dto: SignInDto, device: DeviceMetadata) { @@ -51,6 +54,10 @@ export class AuthFacade { return this.signUpUseCase.execute(dto); } + async resendCode(dto: ResendCodeDto) { + return this.resendCodeUseCase.execute(dto); + } + async verifySignUp(dto: VerifyDto, device: DeviceMetadata) { return this.signUpVerifyUseCase.execute(dto, device); } diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controller/auth/controller.ts index d90d13f..d393b7f 100644 --- a/src/auth/application/controller/auth/controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -5,8 +5,9 @@ import { PostRefreshSwagger, PostRegisterSwagger, PostSignUpConfirmSwagger, + ResendCodeSwagger, } from './swagger'; -import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; +import { ResendCodeDto, SignInDto, SignUpDto, VerifyDto } from '../../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; @@ -34,6 +35,13 @@ export class AuthController { return this.facade.signUp(dto); } + @Post('resend') + @ResendCodeSwagger() + @HttpCode(200) + async resendCode(@Body() dto: ResendCodeDto) { + return this.facade.resendCode(dto); + } + @Post('sign-up/confirm') @PostSignUpConfirmSwagger() @HttpCode(201) diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts index e2cb269..08c8581 100644 --- a/src/auth/application/controller/auth/swagger.ts +++ b/src/auth/application/controller/auth/swagger.ts @@ -5,6 +5,7 @@ import { ApiConflict, ApiForbidden, ApiNotFound, + ApiTooManyRequests, ApiUnauthorized, ApiValidationError, } from '@shared/error'; @@ -15,6 +16,8 @@ import { VerifyDto, SessionsResponse, SessionResponse, + ResendCodeDto, + ResendCodeResponse, } from '../../dtos'; import { ActionResponse } from '@shared/dtos'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; @@ -138,3 +141,23 @@ export const DeleteSessionSwagger = () => ApiNotFound('Сессия не найдена или уже истекла'), SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); + +export const ResendCodeSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Повторная отправка кода подтверждения', + description: + 'Отправляет новый код подтверждения на email, связанный с текущей сессией регистрации или сброса пароля.', + }), + ApiBody({ type: ResendCodeDto.Output }), + ApiResponse({ + status: 200, + description: 'Код успешно отправлен.', + type: ResendCodeResponse.Output, + }), + ApiBadRequest('Неверный формат email'), + ApiNotFound('Сессия регистрации или сброса пароля не найдена или истекла'), + ApiTooManyRequests('Превышено количество попыток запроса нового кода на email'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ResendCodeResponse), + ); diff --git a/src/auth/application/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts index 0f3059a..7dcf2ec 100644 --- a/src/auth/application/dtos/auth.dto.ts +++ b/src/auth/application/dtos/auth.dto.ts @@ -63,3 +63,35 @@ export const SignResponseSchema = ActionResponseSchema.extend({ }); export class SignResponse extends createZodDto(SignResponseSchema) {} + +export const ResendCodeSchema = z.object({ + context: z + .enum(['sign-up', 'reset-password'], { + error: 'Выберите корректный контекст: sign-up или reset-password', + }) + .describe('Контекст, для которого нужно отправить код (регистрация или сброс пароля)'), + email: z.email('Некорректный формат email').describe('Email пользователя'), +}); + +export class ResendCodeDto extends createZodDto(ResendCodeSchema) {} + +export const ResendCodeResponseSchema = ActionResponseSchema.extend({ + nextResendAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время, когда можно запросить повторную отправку кода (ISO 8601)'), + retryAfterSeconds: z + .number() + .int() + .positive() + .describe('Секунды до следующей доступной отправки'), + retries: z + .number() + .int() + .nonnegative() + .describe('Количество повторных отправок в текущем окне'), +}); + +export class ResendCodeResponse extends createZodDto(ResendCodeResponseSchema) {} diff --git a/src/auth/application/interfaces/cache-data.interface.ts b/src/auth/application/interfaces/cache-data.interface.ts new file mode 100644 index 0000000..8ba2d18 --- /dev/null +++ b/src/auth/application/interfaces/cache-data.interface.ts @@ -0,0 +1,19 @@ +import { SignUpDto } from '@core/auth/application/dtos'; + +export interface SignUpCacheData { + user: SignUpDto; + password: string; + otp: { + token: string; + secret: string; + }; +} + +export interface ResetPasswordCacheData { + email: string; + otp: { + secret: string; + token: string; + }; + isVerified: boolean; +} diff --git a/src/auth/application/interfaces/index.ts b/src/auth/application/interfaces/index.ts new file mode 100644 index 0000000..bdc8cd4 --- /dev/null +++ b/src/auth/application/interfaces/index.ts @@ -0,0 +1 @@ +export * from './cache-data.interface'; diff --git a/src/auth/application/strategies/index.ts b/src/auth/application/strategies/index.ts new file mode 100644 index 0000000..d72156a --- /dev/null +++ b/src/auth/application/strategies/index.ts @@ -0,0 +1,11 @@ +import { ResendCodeDto } from '../dtos'; +import { ResetPasswordResendStrategy } from './reset-password-resend.strategy'; +import { ResendCodeStrategy } from './resend-code.strategy'; +import { SignUpResendStrategy } from './sign-up-resend.strategy'; + +export const RESEND_CODE_STRATEGIES: Record = { + 'sign-up': new SignUpResendStrategy(), + 'reset-password': new ResetPasswordResendStrategy(), +}; + +export { ResendCodeStrategy } from './resend-code.strategy'; diff --git a/src/auth/application/strategies/resend-code.strategy.ts b/src/auth/application/strategies/resend-code.strategy.ts new file mode 100644 index 0000000..41a6942 --- /dev/null +++ b/src/auth/application/strategies/resend-code.strategy.ts @@ -0,0 +1,26 @@ +import { Queue } from 'bullmq'; +import { ResendCodeDto } from '../dtos'; + +export abstract class ResendCodeStrategy { + abstract readonly context: ResendCodeDto['context']; + abstract readonly successMessage: string; + abstract readonly cacheNotFoundCode: string; + abstract readonly cacheNotFoundMessage: string; + + abstract getCacheKey(email: string): string; + + abstract generateOtp(): Promise<{ token: string; secret: string }>; + + abstract buildNewCacheData( + cachedData: TCacheData, + newToken: string, + newSecret: string, + ): TCacheData; + + abstract dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + cachedData: TCacheData, + ): Promise; +} diff --git a/src/auth/application/strategies/reset-password-resend.strategy.ts b/src/auth/application/strategies/reset-password-resend.strategy.ts new file mode 100644 index 0000000..6d700d4 --- /dev/null +++ b/src/auth/application/strategies/reset-password-resend.strategy.ts @@ -0,0 +1,59 @@ +import { AuthMailJobs } from '@core/auth/domain/enums'; +import { ResetPasswordEvent } from '@core/auth/domain/events'; +import { ResetPasswordCacheData } from '@core/auth/application/interfaces'; +import { + EMAIL_CODE_TTL_SECONDS, + RESET_PASSWORD_CACHE_KEY, +} from '@core/auth/infrastructure/constants'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; +import { ResendCodeStrategy } from './resend-code.strategy'; + +export class ResetPasswordResendStrategy extends ResendCodeStrategy { + readonly context = 'reset-password' as const; + readonly successMessage = 'Повторный код для восстановления пароля отправлен на вашу почту'; + readonly cacheNotFoundCode = 'RESET_SESSION_EXPIRED'; + readonly cacheNotFoundMessage = + 'Время подтверждения истекло или запрос не найден. Запросите код снова.'; + + getCacheKey(email: string): string { + return RESET_PASSWORD_CACHE_KEY(email); + } + + async generateOtp(): Promise<{ token: string; secret: string }> { + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + return { token, secret }; + } + + buildNewCacheData( + cachedData: ResetPasswordCacheData, + newToken: string, + newSecret: string, + ): ResetPasswordCacheData { + return { + ...cachedData, + otp: { token: newToken, secret: newSecret }, + isVerified: false, + }; + } + + async dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + _cachedData: ResetPasswordCacheData, + ): Promise { + const event = new ResetPasswordEvent(email, token); + await mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + } +} diff --git a/src/auth/application/strategies/sign-up-resend.strategy.ts b/src/auth/application/strategies/sign-up-resend.strategy.ts new file mode 100644 index 0000000..e614f86 --- /dev/null +++ b/src/auth/application/strategies/sign-up-resend.strategy.ts @@ -0,0 +1,55 @@ +import { AuthMailJobs } from '@core/auth/domain/enums'; +import { RegisterCodeEvent } from '@core/auth/domain/events'; +import { SignUpCacheData } from '@core/auth/application/interfaces'; +import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; +import { ResendCodeStrategy } from './resend-code.strategy'; + +export class SignUpResendStrategy extends ResendCodeStrategy { + readonly context = 'sign-up' as const; + readonly successMessage = 'Повторный код подтверждения отправлен на вашу почту'; + readonly cacheNotFoundCode = 'REGISTRATION_EXPIRED'; + readonly cacheNotFoundMessage = 'Срок регистрации истек или email не найден. Попробуйте снова.'; + + getCacheKey(email: string): string { + return SIGNUP_CACHE_KEY(email); + } + + async generateOtp(): Promise<{ token: string; secret: string }> { + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + return { token, secret }; + } + + buildNewCacheData( + cachedData: SignUpCacheData, + newToken: string, + newSecret: string, + ): SignUpCacheData { + return { + ...cachedData, + otp: { token: newToken, secret: newSecret }, + }; + } + + async dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + cachedData: SignUpCacheData, + ): Promise { + const event = new RegisterCodeEvent(email, cachedData.user.firstName, token); + await mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + } +} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index 0f8f6ab..330cd52 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -15,6 +15,7 @@ import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case'; import { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case'; import { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case'; import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case'; +import { ResendCodeUseCase } from './resend-code.use-case'; export { ConfirmResetPasswordUseCase, @@ -34,6 +35,7 @@ export { SignInUseCase, SignOutUseCase, SignUpUseCase, + ResendCodeUseCase, }; export const AuthUseCases = [ @@ -55,4 +57,5 @@ export const AuthUseCases = [ SignInUseCase, SignOutUseCase, SignUpUseCase, + ResendCodeUseCase, ]; diff --git a/src/auth/application/use-cases/resend-code.use-case.ts b/src/auth/application/use-cases/resend-code.use-case.ts new file mode 100644 index 0000000..d94b291 --- /dev/null +++ b/src/auth/application/use-cases/resend-code.use-case.ts @@ -0,0 +1,142 @@ +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { InjectQueue } from '@nestjs/bullmq'; +import { AuthQueues } from '@core/auth/domain/enums'; +import { Queue } from 'bullmq'; +import { ResendCodeDto } from '@core/auth/application/dtos'; +import { + EMAIL_CODE_TTL_SECONDS, + MAX_ATTEMPTS, + RESEND_ATTEMPTS_KEY, + RESEND_COOLDOWN_KEY, + SECONDS_BETWEEN_ATTEMPTS, +} from '@core/auth/infrastructure/constants'; +import { RESEND_CODE_STRATEGIES, ResendCodeStrategy } from '../strategies'; + +@Injectable() +export class ResendCodeUseCase { + private readonly logger = new Logger('TEST'); + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + ) {} + + async execute(dto: ResendCodeDto) { + const strategy = this.getStrategy(dto.context); + const cacheKey = strategy.getCacheKey(dto.email); + const cachedDataStr = await this.cacheService.getOne(cacheKey); + + if (!cachedDataStr) { + throw new BaseException( + { + code: strategy.cacheNotFoundCode, + message: strategy.cacheNotFoundMessage, + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const cooldownKey = RESEND_COOLDOWN_KEY(dto.context, dto.email); + const { ttlSeconds: cooldownTtl } = await this.cacheService.getOneWithTtl(cooldownKey); + + if (cooldownTtl > 0) { + throw new BaseException( + { + code: 'RESEND_RATE_LIMIT', + message: `Повторная отправка доступна через ${this.formatWaitTime(cooldownTtl)}`, + details: [ + { + target: 'email', + value: dto.email, + ttlSeconds: cooldownTtl, + nextResendAt: this.buildResendTiming(cooldownTtl).nextResendAt, + }, + ], + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + const attemptsKey = RESEND_ATTEMPTS_KEY(dto.context, dto.email); + const attemptsStr = await this.cacheService.getOne(attemptsKey); + + let attemptsLeft = attemptsStr ? parseInt(attemptsStr, 10) : MAX_ATTEMPTS; + this.logger.error(attemptsLeft); + if (attemptsLeft <= 0) { + throw new BaseException( + { + code: 'MAX_ATTEMPTS_REACHED', + message: + 'Превышено максимальное количество попыток отправки кода. Начните процесс заново позже.', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.FORBIDDEN, + ); + } + + attemptsLeft -= 1; + + const cachedData = JSON.parse(cachedDataStr); + const { token, secret } = await strategy.generateOtp(); + const newCacheData = strategy.buildNewCacheData(cachedData, token, secret); + + await this.cacheService.setOne( + cacheKey, + JSON.stringify(newCacheData), + EMAIL_CODE_TTL_SECONDS, + ); + + await this.cacheService.setOne( + attemptsKey, + attemptsLeft.toString(), + EMAIL_CODE_TTL_SECONDS, + ); + + await this.cacheService.setOne(cooldownKey, 'locked', SECONDS_BETWEEN_ATTEMPTS); + + await strategy.dispatchEmail(this.mailQueue, dto.email, token, cachedData); + + return { + success: true, + message: strategy.successMessage, + retries: attemptsLeft, + ...this.buildResendTiming(SECONDS_BETWEEN_ATTEMPTS), + }; + } + + private getStrategy(context: ResendCodeDto['context']): ResendCodeStrategy { + const strategy = RESEND_CODE_STRATEGIES[context]; + + if (!strategy) { + throw new BaseException( + { code: 'STRATEGY_NOT_FOUND', message: `No strategy for ${context}` }, + HttpStatus.BAD_REQUEST, + ); + } + + return strategy; + } + + private buildResendTiming(retryAfterSeconds: number) { + return { + retryAfterSeconds, + nextResendAt: new Date(Date.now() + retryAfterSeconds * 1000).toISOString(), + }; + } + + private formatWaitTime(totalSeconds: number) { + const minutesLeft = Math.floor(totalSeconds / 60); + const secondsLeft = totalSeconds % 60; + + if (minutesLeft > 0) { + return `${minutesLeft} мин ${secondsLeft} сек`; + } + + return `${secondsLeft} сек`; + } +} diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts index b265a05..d257efe 100644 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -9,6 +9,11 @@ import { ResetPasswordDto } from '../dtos'; import { FindUserQuery } from '@core/user'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { + EMAIL_CODE_TTL_SECONDS, + RESET_PASSWORD_CACHE_KEY, +} from '@core/auth/infrastructure/constants'; +import { ResetPasswordCacheData } from '@core/auth/application/interfaces'; @Injectable() export class ResetPasswordUseCase { @@ -21,8 +26,7 @@ export class ResetPasswordUseCase { ) {} async execute(dto: ResetPasswordDto) { - const redisKey = `pass:reset:${dto.email}`; - const isExistsAttempt = await this.cacheService.getOne(redisKey); + const isExistsAttempt = await this.cacheService.getOne(RESET_PASSWORD_CACHE_KEY(dto.email)); if (isExistsAttempt) { throw new BaseException( @@ -58,17 +62,21 @@ export class ResetPasswordUseCase { const token = await generate({ secret, digits: 6, - period: 900, + period: EMAIL_CODE_TTL_SECONDS, strategy: 'totp', }); - const resetPayload = { + const resetPayload: ResetPasswordCacheData = { email: entity.user.email, otp: { secret, token }, isVerified: false, }; - await this.cacheService.setOne(redisKey, JSON.stringify(resetPayload), 900); + await this.cacheService.setOne( + RESET_PASSWORD_CACHE_KEY(dto.email), + JSON.stringify(resetPayload), + EMAIL_CODE_TTL_SECONDS, + ); const event = new ResetPasswordEvent(dto.email, token); await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { 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 index 155951c..4de0228 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -9,6 +9,8 @@ import { VerifyDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { SignUpCacheData } from '@core/auth/application/interfaces'; @Injectable() export class SignUpVerifyUseCase { @@ -22,8 +24,7 @@ export class SignUpVerifyUseCase { ) {} async execute(dto: VerifyDto, meta: DeviceMetadata) { - const redisKey = `reg:${dto.email}`; - const cachedData = await this.cacheService.getOne(redisKey); + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); if (!cachedData) { throw new BaseException( @@ -35,7 +36,7 @@ export class SignUpVerifyUseCase { ); } - const userData = JSON.parse(cachedData); + const userData: SignUpCacheData = JSON.parse(cachedData); if (!userData) { throw new BaseException( @@ -47,6 +48,17 @@ export class SignUpVerifyUseCase { ); } + if (userData.otp.token !== dto.code) { + throw new BaseException( + { + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'OTP code is invalid or expired' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + const verifyResult = await verifyOTP({ token: dto.code, secret: userData.otp.secret, @@ -88,7 +100,7 @@ export class SignUpVerifyUseCase { expiresAt: expiresAt.toISOString(), }); - await this.cacheService.removeOne(redisKey); + await this.cacheService.removeOne(SIGNUP_CACHE_KEY(dto.email)); return { success: true, diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts index ccc4d03..eee31fb 100644 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -10,6 +10,8 @@ import { RegisterCodeEvent } from '../../domain/events'; import { SignUpDto } from '../dtos'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { SignUpCacheData } from '@core/auth/application/interfaces'; @Injectable() export class SignUpUseCase { @@ -22,8 +24,7 @@ export class SignUpUseCase { ) {} async execute(dto: SignUpDto) { - const redisKey = `reg:${dto.email}`; - const cachedData = await this.cacheService.getOne(redisKey); + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); if (cachedData) { throw new BaseException( @@ -56,17 +57,21 @@ export class SignUpUseCase { secret, algorithm: 'sha256', digits: 6, - period: 900, + period: EMAIL_CODE_TTL_SECONDS, strategy: 'totp', }); - const data = { + const data: SignUpCacheData = { user: dto, password: hashPass, otp: { token, secret }, }; - await this.cacheService.setOne(`reg:${dto.email}`, JSON.stringify(data), 900); + await this.cacheService.setOne( + SIGNUP_CACHE_KEY(dto.email), + JSON.stringify(data), + EMAIL_CODE_TTL_SECONDS, + ); const event = new RegisterCodeEvent(dto.email, dto.firstName, token); await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { diff --git a/src/auth/infrastructure/constants/cache-keys.ts b/src/auth/infrastructure/constants/cache-keys.ts new file mode 100644 index 0000000..61c9b2f --- /dev/null +++ b/src/auth/infrastructure/constants/cache-keys.ts @@ -0,0 +1,11 @@ +export const SIGNUP_CACHE_KEY = (email: string) => `reg:${email}`; +export const RESET_PASSWORD_CACHE_KEY = (email: string) => `pass:reset:${email}`; + +export const RESEND_COOLDOWN_KEY = (context: string, email: string) => + `resend:cooldown:${context}:${email}`; +export const RESEND_ATTEMPTS_KEY = (context: string, email: string) => + `resend:attempts:${context}:${email}`; + +export const EMAIL_CODE_TTL_SECONDS = 900; +export const MAX_ATTEMPTS = 5; +export const SECONDS_BETWEEN_ATTEMPTS = 60; diff --git a/src/auth/infrastructure/constants/index.ts b/src/auth/infrastructure/constants/index.ts index 98fea0c..124a9d9 100644 --- a/src/auth/infrastructure/constants/index.ts +++ b/src/auth/infrastructure/constants/index.ts @@ -1 +1,2 @@ export * from './oauth'; +export * from './cache-keys'; diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index 57489c9..b3a8a47 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -55,6 +55,9 @@ export const ApiValidationError = ( export const ApiConflict = (description: string = 'Ресурс уже существует') => applyDecorators(ApiErrorResponse(409, 'CONFLICT', description)); +export const ApiTooManyRequests = (description: string = 'Слишком много попыток') => + applyDecorators(ApiErrorResponse(429, 'TOO_MANY_REQUESTS', description)); + export const DATABASE_ERRORS: Record = { '23505': { code: 409, msg: 'Запись с таким значением уже существует (дубликат).' }, '23503': { code: 409, msg: 'Ошибка внешнего ключа: связанная запись не найдена.' },