Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/auth/application/auth.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
10 changes: 9 additions & 1 deletion src/auth/application/controller/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/auth/application/controller/auth/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ApiConflict,
ApiForbidden,
ApiNotFound,
ApiTooManyRequests,
ApiUnauthorized,
ApiValidationError,
} from '@shared/error';
Expand All @@ -15,6 +16,8 @@ import {
VerifyDto,
SessionsResponse,
SessionResponse,
ResendCodeDto,
ResendCodeResponse,
} from '../../dtos';
import { ActionResponse } from '@shared/dtos';
import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors';
Expand Down Expand Up @@ -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),
);
32 changes: 32 additions & 0 deletions src/auth/application/dtos/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('Секунды до следующей доступной отправки'),
Comment thread
maksberegovoi marked this conversation as resolved.
retries: z
.number()
.int()
.nonnegative()
.describe('Количество повторных отправок в текущем окне'),
});

export class ResendCodeResponse extends createZodDto(ResendCodeResponseSchema) {}
19 changes: 19 additions & 0 deletions src/auth/application/interfaces/cache-data.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/auth/application/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './cache-data.interface';
11 changes: 11 additions & 0 deletions src/auth/application/strategies/index.ts
Original file line number Diff line number Diff line change
@@ -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<ResendCodeDto['context'], ResendCodeStrategy> = {
'sign-up': new SignUpResendStrategy(),
'reset-password': new ResetPasswordResendStrategy(),
};

export { ResendCodeStrategy } from './resend-code.strategy';
26 changes: 26 additions & 0 deletions src/auth/application/strategies/resend-code.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Queue } from 'bullmq';
import { ResendCodeDto } from '../dtos';

export abstract class ResendCodeStrategy<TCacheData = unknown> {
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<void>;
}
59 changes: 59 additions & 0 deletions src/auth/application/strategies/reset-password-resend.strategy.ts
Original file line number Diff line number Diff line change
@@ -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<ResetPasswordCacheData> {
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<void> {
const event = new ResetPasswordEvent(email, token);
await mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}
}
55 changes: 55 additions & 0 deletions src/auth/application/strategies/sign-up-resend.strategy.ts
Original file line number Diff line number Diff line change
@@ -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<SignUpCacheData> {
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<void> {
const event = new RegisterCodeEvent(email, cachedData.user.firstName, token);
await mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}
}
3 changes: 3 additions & 0 deletions src/auth/application/use-cases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +35,7 @@ export {
SignInUseCase,
SignOutUseCase,
SignUpUseCase,
ResendCodeUseCase,
};

export const AuthUseCases = [
Expand All @@ -55,4 +57,5 @@ export const AuthUseCases = [
SignInUseCase,
SignOutUseCase,
SignUpUseCase,
ResendCodeUseCase,
];
Loading
Loading