/
istok
/
idp_server
Обзор
Документация
Войти
/
istok
/
idp_server
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev
apps/server/src/presentation/auth/oauth.controller.ts
685 строк
21 KB
ArtemAstakhov
Move client to nextjs
22 ноя 2025, 22:42
22 ноя 2025, 22:42
35e7508
Код
Авторство
О чём код?
import { Controller, Get, Post, Query, Body, Res, Req, Headers, Logger, HttpStatus, HttpCode, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { Duration } from 'luxon'; import { AuthorizationService } from 'src/application/auth/services/authorization.service'; import { SessionService } from 'src/application/auth/services/session.service'; import { AuthorizeUseCase } from 'src/application/auth/use-cases/authorize.use-case'; import { ConsentUseCase } from 'src/application/auth/use-cases/consent.use-case'; import { IntrospectionUseCase } from 'src/application/auth/use-cases/introspection.use-case'; import { LoginUseCase } from 'src/application/auth/use-cases/login.use-case'; import { LogoutUseCase } from 'src/application/auth/use-cases/logout.use-case'; import { RefreshTokenUseCase } from 'src/application/auth/use-cases/refresh-token.use-case'; import { RevocationUseCase } from 'src/application/auth/use-cases/revocation.use-case'; import { SignupUseCase } from 'src/application/auth/use-cases/signup.use-case'; import { TelegramLoginUseCase } from 'src/application/auth/use-cases/telegram-login.use-case'; import { TelegramSignupUseCase } from 'src/application/auth/use-cases/telegram-signup.use-case'; import { TokenUseCase } from 'src/application/auth/use-cases/token.use-case'; import { UserInfoUseCase } from 'src/application/auth/use-cases/userinfo.use-case'; import { ValidateRequestUseCase } from 'src/application/auth/use-cases/validate-request.use-case'; import { ClientsService } from 'src/application/clients/services/clients.service'; import { ServerException } from 'src/core/errors/server-exception'; import { I18nTranslationService } from 'src/core/services/i18n.service'; import { AuthorizationRequest, RefreshTokenRequest, TokenRequest, } from 'src/core/types/auth'; import { Environment } from 'src/core/types/environment'; import { ServerExceptionCode } from 'src/core/types/server-exception-codes'; import { ConfigService } from 'src/infrastructure/config/config.service'; import { AuthorizeRequestDto } from './dto/authorize-request.dto'; import { ConsentRequestDto } from './dto/consent-request.dto'; import { ConsentResponseDto } from './dto/consent-response.dto'; import { GetConsentDataQueryDto } from './dto/get-consent-data-query.dto'; import { GetConsentDataResponseDto } from './dto/get-consent-data-response.dto'; import { IntrospectionRequestDto } from './dto/introspection-request.dto'; import { LoginQueryDto } from './dto/login-query.dto'; import { LoginRequestDto } from './dto/login-request.dto'; import { LoginResponseDto } from './dto/login-response.dto'; import { LogoutRequestDto } from './dto/logout-request.dto'; import { RevocationRequestDto } from './dto/revocation-request.dto'; import { SignupQueryDto } from './dto/signup-query.dto'; import { SignupRequestDto } from './dto/signup-request.dto'; import { SignupResponseDto } from './dto/signup-response.dto'; import { TelegramLoginQueryDto } from './dto/telegram-login-query.dto'; import { TelegramSignUpQueryDto } from './dto/telegram-sign-up-query.dto'; import { TelegramWidgetRequestDto } from './dto/telegram-widget-request.dto'; import { TokenRequestDto, GrantType } from './dto/token-request.dto'; import { ValidateRequestDto } from './dto/validate-request.dto'; @ApiTags('OAuth2') @Controller('oauth') export class OAuthController { private readonly logger = new Logger(OAuthController.name); constructor( private readonly authorizeUseCase: AuthorizeUseCase, private readonly loginUseCase: LoginUseCase, private readonly validateRequestUseCase: ValidateRequestUseCase, private readonly signupUseCase: SignupUseCase, private readonly telegramLoginUseCase: TelegramLoginUseCase, private readonly telegramSignupUseCase: TelegramSignupUseCase, private readonly consentUseCase: ConsentUseCase, private readonly tokenUseCase: TokenUseCase, private readonly refreshTokenUseCase: RefreshTokenUseCase, private readonly userInfoUseCase: UserInfoUseCase, private readonly introspectionUseCase: IntrospectionUseCase, private readonly revocationUseCase: RevocationUseCase, private readonly logoutUseCase: LogoutUseCase, private readonly authorizationService: AuthorizationService, private readonly sessionService: SessionService, private readonly configService: ConfigService, private readonly clientsService: ClientsService, private readonly i18n: I18nTranslationService, ) {} /** * OAuth2/OIDC Authorization Endpoint * GET /oauth/authorize */ @ApiOperation({ summary: 'Authorization', operationId: 'authorize' }) @ApiResponse({ status: HttpStatus.FOUND, description: 'Found' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'Internal Server Error', type: ServerException, }) @HttpCode(HttpStatus.FOUND) @Get('authorize') async authorize( @Query() query: AuthorizeRequestDto, @Req() req: Request, @Res() res: Response, ) { const authRequest: AuthorizationRequest = { responseType: query.response_type, clientId: query.client_id, redirectUri: query.redirect_uri, scope: query.scope, state: query.state, codeChallenge: query.code_challenge, codeChallengeMethod: query.code_challenge_method, nonce: query.nonce, }; const sessionId: string | undefined = req.cookies[this.configService.session.cookieName]; const result = await this.authorizeUseCase.execute(authRequest, sessionId); return res.redirect(result.url); } /** * Validate Request Endpoint * POST /oauth/validate-request */ @ApiOperation({ summary: 'Validate Request', operationId: 'validateRequest' }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'No Content' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @HttpCode(HttpStatus.NO_CONTENT) @Post('validate-request') async validateRequest(@Body() validateDto: ValidateRequestDto) { await this.validateRequestUseCase.execute(validateDto.request_id); } @ApiOperation({ summary: 'Login', operationId: 'login' }) @ApiResponse({ status: HttpStatus.OK, description: 'Login successful', type: LoginResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized', type: ServerException, }) @HttpCode(HttpStatus.OK) @Post('login') async login( @Body() loginDto: LoginRequestDto, @Query() query: LoginQueryDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise<LoginResponseDto> { const result = await this.loginUseCase.execute( loginDto.email, loginDto.password, query.request_id, req.ip, req.get('user-agent'), ); // Set session cookie const isProduction = this.configService.environment.env === Environment.Production; res.cookie(this.configService.session.cookieName, result.sessionId, { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' : 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days path: '/', }); return { redirectUrl: result.redirectUrl }; } @ApiOperation({ summary: 'Signup', operationId: 'signup' }) @ApiResponse({ status: HttpStatus.OK, description: 'Signup successful or error occurred', type: SignupResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @HttpCode(HttpStatus.OK) @Post('signup') async signup( @Body() signupDto: SignupRequestDto, @Query() query: SignupQueryDto, ): Promise<SignupResponseDto> { await this.signupUseCase.execute( signupDto.email, signupDto.password, signupDto.name, ); // Return redirect URL for login with success message and request_id if present const params = new URLSearchParams({ success: 'Регистрация успешна! Теперь вы можете войти.', request_id: query.request_id, }); return { redirectUrl: `${this.configService.app.clientUrl}/login?${params.toString()}`, }; } /** * Telegram Login Endpoint * POST /oauth/telegram/login */ @ApiOperation({ summary: 'Telegram Login', operationId: 'telegramLogin' }) @ApiResponse({ status: HttpStatus.FOUND, description: 'Found' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'Internal Server Error', type: ServerException, }) @HttpCode(HttpStatus.FOUND) @Post('telegram/login') async telegramLogin( @Body() widgetDto: TelegramWidgetRequestDto, @Query() query: TelegramLoginQueryDto, @Req() req: Request, @Res() res: Response, ) { try { const result = await this.telegramLoginUseCase.execute( widgetDto, query.request_id, req.ip, req.get('user-agent'), ); // Set session cookie const isProduction = this.configService.environment.env === Environment.Production; res.cookie(this.configService.session.cookieName, result.sessionId, { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' : 'lax', maxAge: Duration.fromObject({ days: 7 }).toMillis(), path: '/', }); return res.redirect(result.redirectUrl); } catch (error) { let errorMessage = 'Ошибка при входе через Telegram'; if (error instanceof ServerException) { errorMessage = error.message; } else { this.logger.error(error); } // Redirect back with error message const params = new URLSearchParams({ error: errorMessage, request_id: query.request_id, }); return res.redirect(`/oauth/login?${params.toString()}`); } } /** * Telegram Signup Endpoint * POST /oauth/telegram/signup */ @ApiOperation({ summary: 'Telegram Signup', operationId: 'telegramSignup' }) @ApiResponse({ status: HttpStatus.FOUND, description: 'Found' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'Internal Server Error', type: ServerException, }) @HttpCode(HttpStatus.FOUND) @Post('telegram/signup') async telegramSignup( @Body() widgetDto: TelegramWidgetRequestDto, @Query() query: TelegramSignUpQueryDto, @Req() req: Request, @Res() res: Response, ) { try { const result = await this.telegramSignupUseCase.execute( widgetDto, query.request_id, req.ip, req.get('user-agent'), ); // Set session cookie const isProduction = this.configService.environment.env === Environment.Production; res.cookie(this.configService.session.cookieName, result.sessionId, { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' : 'lax', maxAge: Duration.fromObject({ days: 7 }).toMillis(), path: '/', }); return res.redirect(result.redirectUrl); } catch (error) { // Redirect back with error message const errorMessage = error instanceof Error ? error.message : 'Ошибка при регистрации через Telegram'; const params = new URLSearchParams({ error: errorMessage, request_id: query.request_id, }); return res.redirect(`/oauth/signup?${params.toString()}`); } } /** * Consent Endpoint * GET/POST /oauth/consent */ @ApiOperation({ summary: 'Get Consent Data', operationId: 'getConsentData' }) @ApiResponse({ status: HttpStatus.OK, description: 'OK', type: GetConsentDataResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @HttpCode(HttpStatus.OK) @Get('consent') async getConsentData( @Query() query: GetConsentDataQueryDto, @Req() req: Request, ): Promise<GetConsentDataResponseDto> { // Verify request exists const authRequest = await this.authorizationService.getAuthorizationRequest( query.request_id, ); if (!authRequest) { throw ServerException.BadRequestException( this.i18n.translate('auth.invalid_or_expired_request_id'), [ { field: 'request_id', message: this.i18n.translate('auth.invalid_or_expired_request_id'), code: ServerExceptionCode.ValidationError, }, ], ); } // Check session const sessionId: string | undefined = req.cookies[this.configService.session.cookieName]; if (!sessionId) { throw ServerException.UnauthorizedException( this.i18n.translate('auth.not_authenticated'), ); } const session = await this.sessionService.getSession(sessionId); if (!session) { throw ServerException.UnauthorizedException( this.i18n.translate('auth.invalid_session'), ); } const requestedScopes = authRequest.scope ?? []; // Get client information const client = await this.clientsService.findByClientId( authRequest.clientId, ); if (!client) { throw ServerException.NotFoundException( this.i18n.translate('auth.client_not_found'), ); } // Scope descriptions mapping const scopeDescriptions: Array<{ name: string; description: string; required: boolean; }> = [ { name: 'openid', description: 'Идентификатор пользователя (OpenID Connect)', required: true, }, { name: 'profile', description: 'Основная информация профиля (имя, фото)', required: true, }, { name: 'email', description: 'Адрес электронной почты', required: true, }, ]; // Build redirect URI for deny action const redirectUri = new URL(authRequest.redirectUri); redirectUri.searchParams.set('error', 'access_denied'); redirectUri.searchParams.set('error_description', 'User denied access'); if (authRequest.state) { redirectUri.searchParams.set('state', authRequest.state); } return { requestId: query.request_id, rejectRedirectUri: redirectUri.toString(), clientName: client.name, scopes: scopeDescriptions.filter((scope) => requestedScopes.includes(scope.name), ), }; } @ApiOperation({ summary: 'Consent', operationId: 'consent' }) @ApiResponse({ status: HttpStatus.OK, description: 'OK', type: ConsentResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Bad Request', type: ServerException, }) @HttpCode(HttpStatus.OK) @Post('consent') async consent( @Body() consentDto: ConsentRequestDto, @Req() req: Request, ): Promise<ConsentResponseDto> { const sessionId: string | undefined = req.cookies[this.configService.session.cookieName]; const result = await this.consentUseCase.execute( consentDto.request_id, consentDto.scopes, sessionId, ); return { redirectUrl: result.redirectUrl }; } /** * OAuth2 Token Endpoint * POST /oauth/token */ @Post('token') async token( @Body() tokenDto: TokenRequestDto, @Headers('authorization') authorization: string | undefined, ) { if (tokenDto.grant_type === GrantType.AuthorizationCode) { if (!tokenDto.redirect_uri) { throw ServerException.BadRequestException('redirect_uri is required', [ { field: 'redirect_uri', message: 'redirect_uri is required', code: ServerExceptionCode.ValidationError, }, ]); } if (!tokenDto.code) { throw ServerException.BadRequestException('code is required', [ { field: 'code', message: 'code is required', code: ServerExceptionCode.ValidationError, }, ]); } const request: TokenRequest = { grantType: tokenDto.grant_type, code: tokenDto.code, redirectUri: tokenDto.redirect_uri, codeVerifier: tokenDto.code_verifier, clientId: tokenDto.client_id, clientSecret: tokenDto.client_secret, authorization, }; return await this.tokenUseCase.execute(request); } else if (tokenDto.grant_type === GrantType.RefreshToken) { if (!tokenDto.refresh_token) { throw ServerException.BadRequestException('refresh_token is required', [ { field: 'refresh_token', message: 'refresh_token is required', code: ServerExceptionCode.ValidationError, }, ]); } const scopes = tokenDto.scope ? tokenDto.scope.split(' ').filter(Boolean) : undefined; const request: RefreshTokenRequest = { grantType: tokenDto.grant_type, refreshToken: tokenDto.refresh_token, scope: scopes?.join(' '), clientId: tokenDto.client_id, clientSecret: tokenDto.client_secret, authorization, }; return await this.refreshTokenUseCase.execute(request); } else { throw ServerException.BadRequestException('Unsupported grant_type'); } } /** * OIDC UserInfo Endpoint * GET /oauth/userinfo */ @Get('userinfo') async userinfo(@Headers('authorization') authorization: string | undefined) { if (!authorization) { throw ServerException.UnauthorizedException( 'Missing authorization header', ); } // Extract Bearer token const parts = authorization.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { throw ServerException.UnauthorizedException( 'Invalid authorization header format', ); } const accessToken = parts[1]; return await this.userInfoUseCase.execute(accessToken); } /** * OAuth2 Token Introspection Endpoint (RFC 7662) * POST /oauth/introspect */ @Post('introspect') async introspect( @Body() introspectionDto: IntrospectionRequestDto, @Headers('authorization') authorization: string | undefined, ) { const request = { token: introspectionDto.token, tokenTypeHint: introspectionDto.token_type_hint, authorization, clientId: introspectionDto.client_id, clientSecret: introspectionDto.client_secret, }; return await this.introspectionUseCase.execute(request); } /** * OAuth2 Token Revocation Endpoint (RFC 7009) * POST /oauth/revoke */ @Post('revoke') async revoke( @Body() revocationDto: RevocationRequestDto, @Headers('authorization') authorization: string | undefined, ) { const request = { token: revocationDto.token, tokenTypeHint: revocationDto.token_type_hint, authorization, clientId: revocationDto.client_id, clientSecret: revocationDto.client_secret, }; await this.revocationUseCase.execute(request); // RFC 7009: Always return 200 OK, even if token is invalid return {}; } /** * OIDC Logout Endpoint * GET/POST /oauth/logout */ @Get('logout') async logout( @Query() logoutDto: LogoutRequestDto, @Req() req: Request, @Res() res: Response, ) { const sessionId: string | undefined = req.cookies[this.configService.session.cookieName]; const result = await this.logoutUseCase.execute({ sessionId, postLogoutRedirectUri: logoutDto.post_logout_redirect_uri, state: logoutDto.state, }); // Clear session cookie res.clearCookie(this.configService.session.cookieName, { httpOnly: true, secure: this.configService.environment.env === Environment.Production, sameSite: this.configService.environment.env === Environment.Production ? 'none' : 'lax', path: '/', }); // Redirect if post_logout_redirect_uri is provided if (result.postLogoutRedirectUri) { let redirectUrl = result.postLogoutRedirectUri; if (logoutDto.state) { const separator = redirectUrl.includes('?') ? '&' : '?'; redirectUrl = `${redirectUrl}${separator}state=${encodeURIComponent( logoutDto.state, )}`; } return res.redirect(redirectUrl); } return res.json({ success: true }); } @Post('logout') async logoutPost( @Body() logoutDto: LogoutRequestDto, @Req() req: Request, @Res() res: Response, ) { return this.logout(logoutDto, req, res); } }