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
2 changes: 1 addition & 1 deletion libs/bootstrap/src/setups/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function setupSwagger(app: NestFastifyApplication, options: Swagger
extraModels: [GlobalErrorResponse.Output],
});

const customCss = await getCustomCSS();
const customCss = await getCustomCSS().catch(() => '');

SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), {
jsonDocumentUrl: `${path}/s/json`,
Expand Down
7 changes: 7 additions & 0 deletions src/auth/application/auth.facade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';

import {
ExchangeDto,
OAuthResponse,
PasswordResetConfirmDto,
ResendCodeDto,
Expand All @@ -25,6 +26,7 @@ import {
GetConnectedProvidersQuery,
GetEnabledProvidersQuery,
ResendCodeUseCase,
ExchangeUseCase,
} from './use-cases';

import type { DeviceMetadata } from '../infrastructure/utils';
Expand All @@ -46,6 +48,7 @@ export class AuthFacade {
private readonly getConnectedProvidersQuery: GetConnectedProvidersQuery,
private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase,
private readonly resendCodeUseCase: ResendCodeUseCase,
private readonly exchangeTokenUC: ExchangeUseCase,
) {}

public async signIn(dto: SignInDto, device: DeviceMetadata) {
Expand Down Expand Up @@ -84,6 +87,10 @@ export class AuthFacade {
return this.confirmResetPasswordUseCase.execute(dto);
}

public async exchangeToken(dto: ExchangeDto, device: DeviceMetadata) {
return this.exchangeTokenUC.execute(dto, device);
}

public async authenticateOAuth(dto: OAuthResponse, device: DeviceMetadata, state?: string) {
return this.authenticateOAuthUseCase.execute(dto, device, state);
}
Expand Down
88 changes: 67 additions & 21 deletions src/auth/application/controller/oauth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { getDeviceMeta } from '@core/auth/infrastructure/utils';
import { Delete, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import {
Body,
Delete,
Get,
HttpCode,
Param,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators';
import { isBaseException } from '@shared/error';
import { BearerAuthGuard, OAuthGuard } from '@shared/guards';

import { AuthFacade } from '../../auth.facade';
import { ExchangeDto, type TOAuthResponse } from '../../dtos';

import {
DisconnectOAuthProviderSwagger,
Expand All @@ -13,12 +26,12 @@ import {
GetOAuthProvidersSwagger,
OAuthCallbackSwagger,
OAuthLoginSwagger,
ExchangeSwagger,
} from './swagger';

import type { TOAuthResponse } from '../../dtos';
import type { FastifyReply, FastifyRequest } from 'fastify';

@ApiBaseController('auth/oauth', 'OAuth')
@ApiBaseController('oauth', 'OAuth')
export class OAuthController {
private readonly isProduction: boolean = false;
private readonly domain?: string | null = null;
Expand Down Expand Up @@ -50,28 +63,61 @@ export class OAuthController {
const meta = getDeviceMeta(req);
const body = req.user as unknown as TOAuthResponse;
const state = query?.state;
const baseUrl = `https://dev.${this.domain}`;

const dto = {
provider,
id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar_url: body.avatar_url,
sex: body.sex,
bio: body.bio,
};
try {
const dto = {
provider,
id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar_url: body.avatar_url,
sex: body.sex,
bio: body.bio,
};

const result = await this.facade.authenticateOAuth(dto, meta, state);

if (result.isSign) {
res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302);
} else {
res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302);
}
} catch (err) {
const isBaseError = isBaseException(err);

const code = isBaseError
? typeof err.getResponse().valueOf() !== 'object' && String(err)
: String(err);

const message = isBaseError ? err.message : String(err);

const errorQuery = new URLSearchParams({
success: 'false',
message: message || 'Произошла ошибка при авторизации',
code: code || 'OAUTH_ERROR',
});

res.redirect(`${baseUrl}/oauth?${errorQuery.toString()}`, 302);
}
}

const result = await this.facade.authenticateOAuth(dto, meta, state);
@Post('exchange')
@ExchangeSwagger()
@HttpCode(200)
async exchange(
@Body() dto: ExchangeDto,
@Res({ passthrough: true }) res: FastifyReply,
@Req() req: FastifyRequest,
) {
const meta = getDeviceMeta(req);

const baseUrl = `https://dev.${this.domain}`;
const { expiresAt, refresh, ...result } = await this.facade.exchangeToken(dto, meta);

if (result.isSign && result.refresh) {
this.setRefreshCookie(res, result.refresh, result.expiresAt);
res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302);
} else {
res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302);
}
this.setRefreshCookie(res, refresh, expiresAt);

return result;
}

@Get('providers')
Expand Down
30 changes: 28 additions & 2 deletions src/auth/application/controller/oauth/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OAuthProvider } from '@core/auth/infrastructure/constants';
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { ActionResponse } from '@shared/dtos';
import {
ApiBadRequest,
Expand All @@ -11,7 +11,13 @@ import {
} from '@shared/error';
import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors';

import { ConnectedProviders, ConnectProviderResponse, ProvidersResponse } from '../../dtos';
import {
ConnectedProviders,
ConnectProviderResponse,
ExchangeDto,
ExchangeResponse,
ProvidersResponse,
} from '../../dtos';

export const OAuthLoginSwagger = () =>
applyDecorators(
Expand Down Expand Up @@ -151,3 +157,23 @@ export const GetConnectedProvidersSwagger = () =>

SetMetadata(ZOD_RESPONSE_TOKEN, ConnectedProviders),
);
export const ExchangeSwagger = () =>
applyDecorators(
ApiOperation({
summary: 'Обменять одноразовый токен на сессию',
description:
'Обменивает одноразовый exchange-токен, полученный после OAuth авторизации, на полноценную сессию с access и refresh токенами. Устанавливает refresh токен в httpOnly cookie.',
}),
ApiBody({
type: ExchangeDto.Output,
}),
ApiResponse({
status: 200,
description: 'Токен успешно обменян. Возвращает access токен и данные пользователя.',
type: ExchangeResponse.Output,
}),
ApiBadRequest('Неверный запрос. Токен отсутствует, истёк или имеет неверный формат.'),
ApiUnauthorized(),
ApiValidationError(),
SetMetadata(ZOD_RESPONSE_TOKEN, ExchangeResponse),
);
40 changes: 40 additions & 0 deletions src/auth/application/dtos/oauth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,43 @@ export const ConnectProviderSchema = z.object({
});

export class ConnectProviderResponse extends createZodDto(ConnectProviderSchema) {}

export const ExchangeSchema = z.object({
token: z
.string()
.min(32, 'Token must be at least 32 characters')
.max(128, 'Token must not exceed 128 characters')
.regex(/^[a-f0-9]+$/, 'Token must be hexadecimal string'),
});

export class ExchangeDto extends createZodDto(ExchangeSchema) {}

export interface IOAuthExchangeData {
userId: string;
isNewUser: boolean;
email: string;
provider: 'google' | 'yandex' | 'github' | 'vkontakte';
ip: string;
}

export const ExchangeResponseSchema = z.object({
success: z.boolean().describe('Успешность операции'),
message: z
.string()
.min(1, 'message не может быть пустым')
.max(255, 'message не длиннее 255 символов')
.describe('Сообщение для тоста'),
access: z
.string()
.min(10, 'access токен слишком короткий')
.max(500, 'access токен слишком длинный')
.describe('JWT access токен'),
isNewUser: z.boolean().describe('Новый пользователь?'),
provider: z
.enum(['google', 'yandex', 'github', 'vkontakte'], {
message: 'provider должен быть: google, yandex, github или vkontakte',
})
.describe('OAuth провайдер'),
});

export class ExchangeResponse extends createZodDto(ExchangeResponseSchema) {}
39 changes: 21 additions & 18 deletions src/auth/application/use-cases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case';
import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case';
import { ConnectProviderUseCase } from './oauth/connect-provider.use-case';
import { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case';
import { ExchangeUseCase } from './oauth/exchange.use-case';
import { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query';
import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query';
import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case';
Expand Down Expand Up @@ -37,24 +38,26 @@ export const AuthUseCases = [
SignOutUseCase,
SignUpUseCase,
ResendCodeUseCase,
ExchangeUseCase,
];

export { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case';
export { VerifyResetPasswordUseCase } from './verify-reset-password.use-case';
export { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query';
export { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case';
export { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case';
export { ConnectProviderUseCase } from './oauth/connect-provider.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 { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query';
export * from './confirm-reset-password.use-case';
export * from './verify-reset-password.use-case';
export * from './oauth/get-connected-providers.query';
export * from './oauth/disconnect-provider.use-case';
export * from './oauth/authenticate-oauth.use-case';
export * from './oauth/connect-provider.use-case';
export * from './refresh-tokens.use-case';
export * from './reset-password.use-case';
export * from './sign-up-verify.use-case';
export * from './oauth/get-enabled-providers.query';
export * from './oauth/exchange.use-case';

export { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case';
export { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case';
export { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case';
export { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case';
export { SignInUseCase } from './sign-in.use-case';
export { SignOutUseCase } from './sign-out.use-case';
export { SignUpUseCase } from './sign-up.use-case';
export { ResendCodeUseCase } from './resend-code.use-case';
export * from './oauth/oauth-orchestrator.use-case';
export * from './oauth/process-oauth-login.use-case';
export * from './oauth/process-oauth-registration.use-case';
export * from './oauth/connect-oauth-provider.use-case';
export * from './sign-in.use-case';
export * from './sign-out.use-case';
export * from './sign-up.use-case';
export * from './resend-code.use-case';
45 changes: 23 additions & 22 deletions src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { ISessionRepository } from '@core/auth/domain/repository';
import { TokenService } from '@core/auth/infrastructure/security';
import crypto from 'node:crypto';

import { Inject, Injectable } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { CACHE_SERVICE } from '@shared/adapters/cache/constants';
import { ICacheService } from '@shared/adapters/cache/ports';

import { EXCHANGE_TOKEN_NAME, EXCHANGE_TOKEN_TTL } from '../../../infrastructure/constants';

import { OAuthOrchestratorUseCase } from './oauth-orchestrator.use-case';

Expand All @@ -11,10 +14,9 @@ import type { DeviceMetadata } from '@core/auth/infrastructure/utils';
@Injectable()
export class AuthenticateOAuthUseCase {
constructor(
@Inject(CACHE_SERVICE)
private readonly cacheService: ICacheService,
private readonly orchestrator: OAuthOrchestratorUseCase,
@Inject('ISessionRepository')
private readonly sessionRepo: ISessionRepository,
private readonly tokenService: TokenService,
) {}

async execute(dto: OAuthResponse, meta: DeviceMetadata, state?: string) {
Expand All @@ -33,28 +35,27 @@ export class AuthenticateOAuthUseCase {
expiresAt: null,
};
}
const token = crypto.randomBytes(32).toString('hex');

const sessionId = createId();
const { access, expiresAt, refresh } = await this.tokenService.generateTokens(
user,
sessionId,
);

await this.sessionRepo.create({
id: sessionId,
...meta,
expiresAt: expiresAt.toISOString(),
const data = {
userId: user.id,
});
isNewUser,
email: user.email,
provider: dto.provider,
ip: meta.ip,
};

await this.cacheService.setOne(
EXCHANGE_TOKEN_NAME(token),
JSON.stringify(data),
EXCHANGE_TOKEN_TTL,
);

const query = new URLSearchParams({
token,
success: 'true',
message: isNewUser ? 'Регистрация успешна' : 'Вход успешен',
access,
provider: dto.provider,
isNewUser: String(isNewUser),
});

return { query, refresh, expiresAt, isSign: true };
return { query, isSign: true };
}
}
Loading
Loading