From dca67cfe44e72cf435818d90f65325c331dc5c0d Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 16 Jun 2026 02:15:08 +0300 Subject: [PATCH 1/2] fix: oauth intergration and refactor process flow --- libs/bootstrap/src/setups/swagger.ts | 2 +- src/auth/application/auth.facade.ts | 7 ++ .../controller/oauth/controller.ts | 38 ++++++- .../application/controller/oauth/swagger.ts | 30 +++++- src/auth/application/dtos/oauth.dto.ts | 40 ++++++++ src/auth/application/use-cases/index.ts | 39 ++++---- .../oauth/authenticate-oauth.use-case.ts | 46 ++++----- .../use-cases/oauth/exchange.use-case.ts | 99 +++++++++++++++++++ .../oauth/oauth-orchestrator.use-case.ts | 6 ++ .../infrastructure/constants/cache-keys.ts | 3 + .../infrastructure/security/token.service.ts | 3 +- .../strategies/github.strategy.ts | 4 +- .../strategies/google.strategy.ts | 4 +- .../strategies/vkontakte.strategy.ts | 15 ++- .../strategies/yandex.strategy.ts | 4 +- .../infrastructure/utils/ensure-email.util.ts | 22 +++++ src/auth/infrastructure/utils/index.ts | 1 + .../application/use-cases/find-user.query.ts | 2 +- .../repositories/user.repository.ts | 26 ++--- 19 files changed, 323 insertions(+), 68 deletions(-) create mode 100644 src/auth/application/use-cases/oauth/exchange.use-case.ts create mode 100644 src/auth/infrastructure/utils/ensure-email.util.ts diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts index 46e060d..9f5820b 100644 --- a/libs/bootstrap/src/setups/swagger.ts +++ b/libs/bootstrap/src/setups/swagger.ts @@ -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`, diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts index 817f28b..c1d5b34 100644 --- a/src/auth/application/auth.facade.ts +++ b/src/auth/application/auth.facade.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { + ExchangeDto, OAuthResponse, PasswordResetConfirmDto, ResendCodeDto, @@ -25,6 +26,7 @@ import { GetConnectedProvidersQuery, GetEnabledProvidersQuery, ResendCodeUseCase, + ExchangeUseCase, } from './use-cases'; import type { DeviceMetadata } from '../infrastructure/utils'; @@ -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) { @@ -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); } diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controller/oauth/controller.ts index 0b90d1e..fb0859f 100644 --- a/src/auth/application/controller/oauth/controller.ts +++ b/src/auth/application/controller/oauth/controller.ts @@ -1,10 +1,22 @@ 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 { BearerAuthGuard, OAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; +import { ExchangeDto, type TOAuthResponse } from '../../dtos'; import { DisconnectOAuthProviderSwagger, @@ -13,12 +25,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; @@ -66,14 +78,30 @@ export class OAuthController { const baseUrl = `https://dev.${this.domain}`; - if (result.isSign && result.refresh) { - this.setRefreshCookie(res, result.refresh, result.expiresAt); + if (result.isSign) { res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); } else { res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302); } } + @Post('exchange') + @ExchangeSwagger() + @HttpCode(200) + async exchange( + @Body() dto: ExchangeDto, + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const meta = getDeviceMeta(req); + + const { expiresAt, refresh, ...result } = await this.facade.exchangeToken(dto, meta); + + this.setRefreshCookie(res, refresh, expiresAt); + + return result; + } + @Get('providers') @GetOAuthProvidersSwagger() async getEnabledProviders() { diff --git a/src/auth/application/controller/oauth/swagger.ts b/src/auth/application/controller/oauth/swagger.ts index d02e06f..1aa32bc 100644 --- a/src/auth/application/controller/oauth/swagger.ts +++ b/src/auth/application/controller/oauth/swagger.ts @@ -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, @@ -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( @@ -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), + ); diff --git a/src/auth/application/dtos/oauth.dto.ts b/src/auth/application/dtos/oauth.dto.ts index 3188ef9..2a90476 100644 --- a/src/auth/application/dtos/oauth.dto.ts +++ b/src/auth/application/dtos/oauth.dto.ts @@ -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) {} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index 917ffe5..74ad374 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -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'; @@ -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'; diff --git a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts index deeaebf..0c0f4c6 100644 --- a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts +++ b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts @@ -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'; @@ -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) { @@ -33,28 +35,26 @@ 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({ - success: 'true', - message: isNewUser ? 'Регистрация успешна' : 'Вход успешен', - access, - provider: dto.provider, - isNewUser: String(isNewUser), + token, }); - return { query, refresh, expiresAt, isSign: true }; + return { query, isSign: true }; } } diff --git a/src/auth/application/use-cases/oauth/exchange.use-case.ts b/src/auth/application/use-cases/oauth/exchange.use-case.ts new file mode 100644 index 0000000..00dd97e --- /dev/null +++ b/src/auth/application/use-cases/oauth/exchange.use-case.ts @@ -0,0 +1,99 @@ +import { HttpStatus, 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 { BaseException } from '@shared/error'; + +import { ISessionRepository } from '../../../domain/repository'; +import { EXCHANGE_TOKEN_NAME } from '../../../infrastructure/constants'; +import { TokenService } from '../../../infrastructure/security'; +import { ExchangeDto, type IOAuthExchangeData } from '../../dtos'; + +import type { DeviceMetadata } from '../../../infrastructure/utils'; + +@Injectable() +export class ExchangeUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly tokenService: TokenService, + ) {} + + async execute(dto: ExchangeDto, meta: DeviceMetadata) { + const key = EXCHANGE_TOKEN_NAME(dto.token); + const rawData = await this.cacheService.getOne(key); + + if (!rawData) { + throw new BaseException( + { + message: 'Exchange token is invalid or expired', + code: 'EXCHANGE_TOKEN_INVALID', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const data = JSON.parse(rawData) as IOAuthExchangeData; + await this.cacheService.removeOne(key); + + if (!data.userId || !data.email) { + await this.cacheService.removeOne(key); + throw new BaseException( + { + message: 'Неверный формат данных авторизации', + code: 'EXCHANGE_DATA_CORRUPTED', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + const sessionId = createId(); + const { access, expiresAt, refresh } = await this.tokenService.generateTokens( + { id: data.userId, email: data.email }, + sessionId, + ); + + const result = await this.sessionRepo.create({ + id: sessionId, + ...meta, + expiresAt: expiresAt.toISOString(), + userId: data.userId, + }); + + if (!result?.id) { + throw new BaseException( + { + message: 'Не удалось создать сессию', + code: 'SESSION_CREATION_FAILED', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Вход выполнен успешно', + access, + isNewUser: data.isNewUser, + provider: data.provider, + refresh, + expiresAt, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + message: 'Внутренняя ошибка сервера при создании сессии', + code: 'SESSION_CREATION_INTERNAL_ERROR', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts index 62df86e..bec43c4 100644 --- a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts +++ b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts @@ -25,6 +25,12 @@ export class OAuthOrchestratorUseCase { ) {} async execute(dto: OAuthResponse, state?: string) { + console.log('[OAuth] Start:', { + provider: dto.provider, + email: dto.email, + hasState: !!state, + }); + if (state) { try { return this.connectProvider.execute(dto, state); diff --git a/src/auth/infrastructure/constants/cache-keys.ts b/src/auth/infrastructure/constants/cache-keys.ts index 61c9b2f..2a3a6bf 100644 --- a/src/auth/infrastructure/constants/cache-keys.ts +++ b/src/auth/infrastructure/constants/cache-keys.ts @@ -9,3 +9,6 @@ export const RESEND_ATTEMPTS_KEY = (context: string, email: string) => export const EMAIL_CODE_TTL_SECONDS = 900; export const MAX_ATTEMPTS = 5; export const SECONDS_BETWEEN_ATTEMPTS = 60; + +export const EXCHANGE_TOKEN_TTL = 10 * 60; // 10 минут +export const EXCHANGE_TOKEN_NAME = (token: string) => `oauth:exchange:${token}`; diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index 9655ee4..383caff 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import type { User } from '@core/user'; import type { JwtPayload } from '@shared/types'; @Injectable() @@ -12,7 +11,7 @@ export class TokenService { private readonly cfg: ConfigService, ) {} - async generateTokens(user: User, sessionId: string) { + async generateTokens(user: { id: string; email: string }, sessionId: string) { const iss = this.cfg.getOrThrow('JWT_ISSUER'); const aud = this.cfg.getOrThrow('JWT_AUDIENCE'); diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts index e89e6a0..0dd8f03 100644 --- a/src/auth/infrastructure/strategies/github.strategy.ts +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, type Profile } from 'passport-github'; +import { ensureEmail } from '../utils'; + interface GitHubJsonProfile { readonly login: string; readonly id: number; @@ -44,7 +46,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { const user = { id: json.id.toString(), - email: json.email || `${json.login}@github.placeholder.internal`, + email: ensureEmail(json.email, 'github', json.id.toString(), json.login), first_name: json.name || json.login, last_name: null, sex: null, diff --git a/src/auth/infrastructure/strategies/google.strategy.ts b/src/auth/infrastructure/strategies/google.strategy.ts index fb22e8e..a12c34e 100644 --- a/src/auth/infrastructure/strategies/google.strategy.ts +++ b/src/auth/infrastructure/strategies/google.strategy.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, type VerifyCallback, type Profile } from 'passport-google-oauth20'; +import { ensureEmail } from '../utils'; + @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google-oauth') { constructor(cfg: ConfigService) { @@ -29,7 +31,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google-oauth') { const user = { id: profile.id, - email: json.email, + email: ensureEmail(json.email, 'google', profile.id, json.given_name), avatar_url: json.picture || null, first_name: json.given_name, last_name: json.family_name, diff --git a/src/auth/infrastructure/strategies/vkontakte.strategy.ts b/src/auth/infrastructure/strategies/vkontakte.strategy.ts index e7d2d18..34a6de7 100644 --- a/src/auth/infrastructure/strategies/vkontakte.strategy.ts +++ b/src/auth/infrastructure/strategies/vkontakte.strategy.ts @@ -6,6 +6,8 @@ import { BaseException } from '@shared/error'; import { Strategy } from 'passport-oauth2'; import { firstValueFrom } from 'rxjs'; +import { ensureEmail } from '../utils'; + export interface IVKUserInfo { readonly id: number; readonly first_name: string; @@ -120,7 +122,12 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau ) { const user = { id: profile.id, - email: `${profile.displayName}@vk.placholder.internal`, + email: ensureEmail( + profile.emails?.[0]?.value, + 'vkontakte', + profile.id, + profile.displayName, + ), first_name: profile.name.givenName, last_name: profile.name.familyName, sex: profile.gender === 'male' ? 'male' : profile.gender === 'female' ? 'female' : null, @@ -233,6 +240,10 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau const finalPhotos = photos.length === 0 && json.photo_max ? [...photos, { value: json.photo_max }] : photos; + const email = json.contacts?.mobile_phone + ? `${json.contacts.mobile_phone}@vk.phone.internal` + : undefined; + const profile: IVKProfile = { provider: 'vkontakte', id: String(json.id), @@ -242,7 +253,7 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau givenName: json.first_name || '', }, gender, - emails: [], + emails: email ? [{ value: email }] : [], photos: finalPhotos, _raw: JSON.stringify(json), _json: json, diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts index 8ebbdba..ff08a9f 100644 --- a/src/auth/infrastructure/strategies/yandex.strategy.ts +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -6,6 +6,8 @@ import { BaseException } from '@shared/error'; import { Strategy } from 'passport-oauth2'; import { firstValueFrom } from 'rxjs'; +import { ensureEmail } from '../utils'; + export interface IUserInfo { readonly id: string; readonly login: string; @@ -110,7 +112,7 @@ export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { id: String(data.id), displayName: data.display_name || data.real_name || data.login, username: data.login, - emails: [{ value: data.default_email }], + email: ensureEmail(data.default_email, 'yandex', data.id.toString(), data.login), name: { familyName: data.last_name || '', givenName: data.first_name || '', diff --git a/src/auth/infrastructure/utils/ensure-email.util.ts b/src/auth/infrastructure/utils/ensure-email.util.ts new file mode 100644 index 0000000..6a80cb6 --- /dev/null +++ b/src/auth/infrastructure/utils/ensure-email.util.ts @@ -0,0 +1,22 @@ +export function ensureEmail( + email: string | null | undefined, + provider: string, + id: string, + login?: string, +): string { + if (email?.trim() && email.includes('@')) { + return email; + } + + const providers: Record = { + github: 'github', + vkontakte: 'vk', + yandex: 'yandex', + google: 'google', + }; + + const domain = providers[provider] ?? provider; + const username = login || id; + + return `${username}@${domain}.placeholder.internal`; +} diff --git a/src/auth/infrastructure/utils/index.ts b/src/auth/infrastructure/utils/index.ts index 28854a7..b84c0e3 100644 --- a/src/auth/infrastructure/utils/index.ts +++ b/src/auth/infrastructure/utils/index.ts @@ -1 +1,2 @@ export { getDeviceMeta, type DeviceMetadata } from './get-device-meta'; +export * from './ensure-email.util'; diff --git a/src/user/application/use-cases/find-user.query.ts b/src/user/application/use-cases/find-user.query.ts index 3ec9225..cb4b06f 100644 --- a/src/user/application/use-cases/find-user.query.ts +++ b/src/user/application/use-cases/find-user.query.ts @@ -9,7 +9,7 @@ export class FindUserQuery { private readonly repository: IUserRepository, ) {} - async execute(params: { readonly email?: string; readonly id?: string }) { + async execute(params: { email?: string; id?: string }) { if (params.email) { return this.repository.findByEmail(params.email); } diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index e462822..7362f53 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -29,7 +29,7 @@ export class UserRepository implements IUserRepository { .leftJoin(sc.userNotifications, eq(sc.users.id, sc.userNotifications.userId)); } - public readonly findProfile = async (id: string) => { + public findProfile = async (id: string) => { const [rows] = await this.fullUserQuery .leftJoin(sc.userPreferences, eq(sc.users.id, sc.userPreferences.userId)) .where(eq(sc.users.id, id)); @@ -63,7 +63,7 @@ export class UserRepository implements IUserRepository { }; }; - public readonly findByIds = async (ids: readonly string[]) => { + public findByIds = async (ids: string[]) => { if (ids.length === 0) { return []; } @@ -71,7 +71,7 @@ export class UserRepository implements IUserRepository { return this.db.select().from(sc.users).where(inArray(sc.users.id, ids)); }; - public readonly findById = async (id: string) => { + public findById = async (id: string) => { const [row] = await this.fullUserQuery.where(eq(sc.users.id, id)); if (!row || !row.user_security) { return null; @@ -84,7 +84,7 @@ export class UserRepository implements IUserRepository { }; }; - public readonly findByEmail = async (email: string) => { + public findByEmail = async (email: string) => { const [row] = await this.fullUserQuery.where(eq(sc.users.email, email.toLowerCase())); if (!row || !row.user_security) { return null; @@ -97,7 +97,7 @@ export class UserRepository implements IUserRepository { }; }; - public readonly findSecurityByUserId = async (userId: string) => { + public findSecurityByUserId = async (userId: string) => { const [result] = await this.db .select() .from(sc.userSecurity) @@ -105,7 +105,7 @@ export class UserRepository implements IUserRepository { return result || null; }; - public readonly create = async (data: NewUser) => + public create = async (data: NewUser) => this.db.transaction(async (tx) => { const [newUser] = await tx.insert(sc.users).values(data).returning(); @@ -113,6 +113,13 @@ export class UserRepository implements IUserRepository { throw new Error('Failed to create user'); } + await tx.insert(sc.userSecurity).values({ + userId: newUser.id, + is2faEnabled: false, + lastLoginAt: new Date().toISOString(), + passwordHash: null, + }); + await tx.insert(sc.userNotifications).values({ userId: newUser.id, }); @@ -120,7 +127,7 @@ export class UserRepository implements IUserRepository { return newUser; }); - public readonly updateProfile = async ( + public updateProfile = async ( id: string, user: Partial, preferences?: Partial, @@ -209,10 +216,7 @@ export class UserRepository implements IUserRepository { return (result?.count ?? 0) > 0; } - async findActivityByUser( - userId: string, - options: { readonly limit: number; readonly offset: number }, - ) { + async findActivityByUser(userId: string, options: { limit: number; offset: number }) { const [totalResult, items] = await Promise.all([ this.db .select({ value: count() }) From 941ca49681b0d35fd484dc7606b8e2b4011bed62 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 16 Jun 2026 15:36:15 +0300 Subject: [PATCH 2/2] refactor: enchanced strategies urls callbacks, catch exceptions to frontend --- .../controller/oauth/controller.ts | 54 ++++++++++++------- .../oauth/authenticate-oauth.use-case.ts | 1 + .../oauth/oauth-orchestrator.use-case.ts | 17 +----- .../strategies/github.strategy.ts | 2 +- .../strategies/google.strategy.ts | 2 +- .../strategies/vkontakte.strategy.ts | 2 +- .../strategies/yandex.strategy.ts | 2 +- src/shared/error/exception.ts | 8 +++ 8 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controller/oauth/controller.ts index fb0859f..e33c4c8 100644 --- a/src/auth/application/controller/oauth/controller.ts +++ b/src/auth/application/controller/oauth/controller.ts @@ -13,6 +13,7 @@ import { } 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'; @@ -62,26 +63,43 @@ export class OAuthController { const meta = getDeviceMeta(req); const body = req.user as unknown as TOAuthResponse; const state = query?.state; - - 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); - const baseUrl = `https://dev.${this.domain}`; - if (result.isSign) { - res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); - } else { - res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302); + 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); } } diff --git a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts index 0c0f4c6..1346ff1 100644 --- a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts +++ b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts @@ -53,6 +53,7 @@ export class AuthenticateOAuthUseCase { const query = new URLSearchParams({ token, + success: 'true', }); return { query, isSign: true }; diff --git a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts index bec43c4..9072616 100644 --- a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts +++ b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { BaseException, type IErrorOptions } from '@shared/error'; +import { isBaseExceptionWithCode } from '@shared/error'; import { OAuthResponse } from '../../dtos'; @@ -7,15 +7,6 @@ import { ConnectOAuthProviderUseCase } from './connect-oauth-provider.use-case'; import { ProcessOAuthLoginUseCase } from './process-oauth-login.use-case'; import { ProcessOAuthRegistrationUseCase } from './process-oauth-registration.use-case'; -// TODO: ADD TO GLOBAL -function isBaseException(error: unknown): error is BaseException { - return error instanceof BaseException; -} - -function isBaseExceptionWithCode(error: unknown, code: string): error is BaseException { - return isBaseException(error) && (error.getResponse() as IErrorOptions).code === code; -} - @Injectable() export class OAuthOrchestratorUseCase { constructor( @@ -25,12 +16,6 @@ export class OAuthOrchestratorUseCase { ) {} async execute(dto: OAuthResponse, state?: string) { - console.log('[OAuth] Start:', { - provider: dto.provider, - email: dto.email, - hasState: !!state, - }); - if (state) { try { return this.connectProvider.execute(dto, state); diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts index 0dd8f03..f1be86b 100644 --- a/src/auth/infrastructure/strategies/github.strategy.ts +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -20,7 +20,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { const isProduction = cfg.get('NODE_ENV') === 'production'; const domain = cfg.get('DOMAIN'); const port = cfg.get('PORT'); - const apiPath = 'v1/auth/oauth/github/callback'; + const apiPath = 'v1/oauth/github/callback'; const callbackURL = domain ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` diff --git a/src/auth/infrastructure/strategies/google.strategy.ts b/src/auth/infrastructure/strategies/google.strategy.ts index a12c34e..333779e 100644 --- a/src/auth/infrastructure/strategies/google.strategy.ts +++ b/src/auth/infrastructure/strategies/google.strategy.ts @@ -11,7 +11,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google-oauth') { const isProduction = cfg.get('NODE_ENV') === 'production'; const domain = cfg.get('DOMAIN'); const port = cfg.get('PORT'); - const apiPath = 'v1/auth/oauth/google/callback'; + const apiPath = 'v1/oauth/google/callback'; const callbackURL = domain ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` diff --git a/src/auth/infrastructure/strategies/vkontakte.strategy.ts b/src/auth/infrastructure/strategies/vkontakte.strategy.ts index 34a6de7..a994bc4 100644 --- a/src/auth/infrastructure/strategies/vkontakte.strategy.ts +++ b/src/auth/infrastructure/strategies/vkontakte.strategy.ts @@ -95,7 +95,7 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau const isProduction = cfg.get('NODE_ENV') === 'production'; const domain = cfg.get('DOMAIN'); const port = cfg.get('PORT'); - const apiPath = 'v1/auth/oauth/yandex/callback'; + const apiPath = 'v1/oauth/yandex/callback'; const callbackURL = domain ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts index ff08a9f..33fad7e 100644 --- a/src/auth/infrastructure/strategies/yandex.strategy.ts +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -52,7 +52,7 @@ export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { const isProduction = cfg.get('NODE_ENV') === 'production'; const domain = cfg.get('DOMAIN'); const port = cfg.get('PORT'); - const apiPath = 'v1/auth/oauth/yandex/callback'; + const apiPath = 'v1/oauth/yandex/callback'; const callbackURL = domain ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` diff --git a/src/shared/error/exception.ts b/src/shared/error/exception.ts index 87bb640..588b638 100644 --- a/src/shared/error/exception.ts +++ b/src/shared/error/exception.ts @@ -16,3 +16,11 @@ export class BaseException extends HttpException { super(options, status); } } + +export function isBaseException(error: unknown): error is BaseException { + return error instanceof BaseException; +} + +export function isBaseExceptionWithCode(error: unknown, code: string): error is BaseException { + return isBaseException(error) && (error.getResponse() as IErrorOptions).code === code; +}