From 7616b1e32c88fb40e9ec5731fd05d6b79a5d8c7c Mon Sep 17 00:00:00 2001 From: Marcos Rodrigues Date: Wed, 3 Dec 2025 13:51:32 -0300 Subject: [PATCH] FEAT: add resource origin validate in me endpoint --- src/modules/auth/auth.controller.ts | 125 ++++---------- src/modules/auth/auth.service.ts | 247 ++++++++++++++++++++++------ 2 files changed, 226 insertions(+), 146 deletions(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 9a4fb01..c648a41 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -13,6 +13,7 @@ import { Req, Param, Res, + UnauthorizedException, } from '@nestjs/common'; import { ApiHeaders, @@ -102,14 +103,13 @@ export class AuthController { const data = await this.authClient.signIn({ username, password, totp }, metadata); if (data.tokens) { - this.addTokenInCookie(res, { + this.authClient.writeAuthSession(res, { accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, userId: data.user.id }); } - return res.send(data); } catch (error) { this.logger.error('/auth - SignIn - ERROR', error); @@ -126,27 +126,8 @@ export class AuthController { ) { try { this.logger.info('/auth - SignOut'); - const exp = 1000 * 60 * 3; - - res.cookie('ddf-auth', '', { - domain: 'dadosfera.local', - maxAge: Date.now() - exp, - expires: new Date(), - httpOnly: true, - secure: true, - sameSite: 'none', // Necessário para cookies em requisições cross-site - }); - - res.cookie('ddf-refresh-auth', '', { - domain: 'dadosfera.local', - maxAge: Date.now() - exp, - expires: new Date(), - httpOnly: true, - secure: true, - sameSite: 'none', // Necessário para cookies em requisições cross-site - }); - - this.logger.info('Clean cookie sessions'); + + this.authClient.cleanUpAuthSession(res); return res.send(); } catch (error) { @@ -175,8 +156,9 @@ export class AuthController { const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata); - this.addTokenInCookie(res, { + this.authClient.writeAuthSession(res, { accessToken: data.accessToken, + refreshToken: data.refreshToken, userId }); @@ -492,85 +474,36 @@ export class AuthController { @Get('me') async getMe(@Req() req: Request, @Res() res: Response) { this.logger.info('GET /auth/me ') - // Lê cookies + + // Get token and headers const accessToken = req.cookies['ddf-auth']; - const userId = req.cookies['ddf-user-id']; + const refreshToken = req.cookies['ddf-refresh-auth']; + const userId = req.cookies['ddf-user-id']; + const resourceOrigin = req.headers["origin"] + + const hasUserSession = Boolean(accessToken) && Boolean(userId); + this.logger.info('Has User Session: ' + hasUserSession); + + if (!hasUserSession) { + throw new UnauthorizedException() + } - this.logger.info('Has cookie: ' + Boolean(accessToken)) try { - // Decodifica e valida o JWT de acesso - const userDto = await this.authClient.extractUserFrom(accessToken); + const userDto = await this.authClient.validateUserSession(accessToken, resourceOrigin); return res.status(200).json(userDto); - } catch (err) { - this.logger.error(err.message); - const refreshToken = req.cookies['ddf-refresh-auth']; + } catch (error) { - this.logger.info('Token is invalid') - this.logger.info('Has Refresh Token: '+ Boolean(refreshToken)) - // Se access token inválido, tenta refresh - if (!refreshToken || !userId) { + if (!refreshToken) { this.logger.error('Invalid refresh token or customer name'); - return res.status(401).json({ error: 'Not authenticated' }); - } - try { - // Chama refreshAccessToken - const metadata = PackTheMetadata({ - }); - this.logger.info('Call Refresh Token') - const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata); - this.logger.info('Finish Refresh Token') - // Retorna novo access token e dados mínimos - this.addTokenInCookie(res, { - accessToken: data.accessToken, - userId - }); - // Decodifica novo token - const userDto = await this.authClient.extractUserFrom(accessToken); - return res.status(200).json(userDto); - } catch (refreshErr) { - this.logger.error(refreshErr) - return res.status(401).json({ error: 'Not authenticated' }); - } - } - } + throw new UnauthorizedException("Invalid refresh token or customer name"); + }; - private addTokenInCookie(res: Response, data: CookiesValues) { - let exp = 1000 * 60 * 5; // 5 minutes - - if (data.accessToken) { - const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload; - exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms - - this.logger.info('Set Cookie ddf-auth') - res.cookie('ddf-auth', data.accessToken, { - domain: '.dadosfera.ai', - maxAge: exp, - httpOnly: true, - secure: true, - sameSite: 'none', // Necessário para cookies em requisições cross-site - }); - } - - if (data.refreshToken) { - this.logger.info('Set Cookie ddf-refresh-auth') - res.cookie('ddf-refresh-auth', data.refreshToken, { - domain: '.dadosfera.ai', - maxAge: exp, - httpOnly: true, - secure: true, - sameSite: 'none', // Necessário para cookies em requisições cross-site - }); - } - - if (data.userId) { - this.logger.info('Set Cookie ddf-refresh-auth') - res.cookie('ddf-user-id', data.userId, { - domain: '.dadosfera.ai', - maxAge: exp, - httpOnly: true, - secure: true, - sameSite: 'none', // Necessário para cookies em requisições cross-site - }); + const { + authSession, + user + } = await this.authClient.refreshUserSession(refreshToken, userId, resourceOrigin); + this.authClient.writeAuthSession(res, authSession); + return res.status(200).json(user); } } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 3a17327..3133462 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,10 +1,20 @@ -import { OnModuleInit, Inject, Injectable, ForbiddenException } from '@nestjs/common'; +import { + OnModuleInit, + Inject, + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; import { ClientGrpc } from '@nestjs/microservices'; import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; import { lastValueFrom } from 'rxjs'; import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc'; -import { AuthProtoService as AuthServiceInterface, IdentityProviderProtoService, UsersProtoService } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service'; +import { + AuthProtoService as AuthServiceInterface, + IdentityProviderProtoService, + UsersProtoService, +} from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service'; import { AuthSnowflakeSignInRequest, AuthSignInRequest, @@ -22,12 +32,18 @@ import { import { DucClient } from '../duc/client.config'; import { Metadata } from '@grpc/grpc-js'; import { BulkEditResponse, UserDTO } from './dtos/login'; -import jwt from 'jsonwebtoken'; +import jwt, { JwtPayload } from 'jsonwebtoken'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; +import { Request, Response } from 'express'; + +type AuthSession = { + accessToken?: string; + refreshToken?: string; + userId?: string; +}; @Injectable() export class AuthClientService implements OnModuleInit { - logger: DadosferaLogger; private authService: AuthServiceInterface; @@ -63,11 +79,11 @@ export class AuthClientService implements OnModuleInit { return lastValueFrom(this.authService.AuthSnowflakeSignIn(input)); } - checkDedicatedProxy({ - customer - }: AuthSignInResponse) { + checkDedicatedProxy({ customer }: AuthSignInResponse) { const DEDICATED_PROXY = process.env.DEDICATED_PROXY || ''; - this.logger.info('SignIn - Setting customer ID for dedicated proxy: ' + DEDICATED_PROXY); + this.logger.info( + 'SignIn - Setting customer ID for dedicated proxy: ' + DEDICATED_PROXY, + ); this.logger.info('Customer ID: ' + customer.id); if (DEDICATED_PROXY !== '' && DEDICATED_PROXY !== customer.id) { @@ -75,7 +91,9 @@ export class AuthClientService implements OnModuleInit { } // Bloquear o customer de acesso o maestro publico - this.logger.info('Check if customer have network policy: ' + customer.modules); + this.logger.info( + 'Check if customer have network policy: ' + customer.modules, + ); const hasNetworkPolicyModule = customer.modules.includes('network-policy'); if (hasNetworkPolicyModule && DEDICATED_PROXY === '') { throw new ForbiddenException(); @@ -94,7 +112,6 @@ export class AuthClientService implements OnModuleInit { result = await lastValueFrom( this.authService.AuthSignIn({ username, password, totp }, metadata), ); - } catch (error) { this.logger.error('SignIn - Error during sign-in'); this.logger.error(error); @@ -105,7 +122,7 @@ export class AuthClientService implements OnModuleInit { this.checkDedicatedProxy(result); } - return result + return result; } async refreshAccessToken( @@ -115,7 +132,10 @@ export class AuthClientService implements OnModuleInit { this.logger.info('RefreshAccessToken'); return lastValueFrom( - this.authService.AuthRefreshAccessToken({ refreshToken, userId }, metadata), + this.authService.AuthRefreshAccessToken( + { refreshToken, userId }, + metadata, + ), ); } @@ -284,50 +304,177 @@ export class AuthClientService implements OnModuleInit { } } - private async getUser(id: string, metadata: Metadata) { - this.logger.info('getUser'); - return await lastValueFrom( - this.userService.UserFindOneById({ id }, metadata), - ); + public async validateUserSession(accessToken: any, originHeader: string) { + const payload = await this.validateJwtToken(accessToken); + + const userDto = await this.getUserfromPayload(payload); + + this.validateResourceAccess(originHeader, userDto); + return userDto; } - public async extractUserFrom(token: string) { - const decoded: any = token && jwt.decode(token, { complete: true }); - if (!decoded) throw new Error('Invalid token'); + public async refreshUserSession( + refreshToken: string, + userId: string, + originHeader: string, + ): Promise<{ + user: UserDTO; + authSession: AuthSession; + }> { + const metadata = PackTheMetadata({}); - const { kid } = decoded.header; - // Busca a chave pública - const { keys } = await this.getPublicKeys(); - const pemValue = keys.find((k) => k.kid === kid)?.pem; - if (!pemValue) - throw new Error('Public key not found'); - jwt.verify(token, pemValue); + this.logger.info('Call Refresh Token'); + const refreshCredentials = await this.refreshAccessToken( + { refreshToken, userId }, + metadata, + ); + this.logger.info('Finish Refresh Token'); - const payload = decoded.payload; - const metadata = PackTheMetadata({ - customer_id: payload.customer_id - }) + const userDto = await this.validateUserSession( + refreshCredentials.accessToken, + originHeader, + ); + return { + user: userDto, + authSession: { + accessToken: refreshCredentials.accessToken, + refreshToken: refreshCredentials.refreshToken, + userId, + }, + }; + } - const { - user - } = await this.getUser( - payload.user_id, - metadata - ); + public writeAuthSession(res: Response, data: AuthSession) { + let exp = 1000 * 60 * 5; // 5 minutes - const userDto: UserDTO = { - id: user.id, - name: user.username, - jobTitle: user?.jobTitle || null, - department: user?.department || null, - hierarchy: user?.hierarchy || null, - customer: { - id: payload.customer_id, - name: payload.customer_name, - tier: payload.customer_tier, - } - }; + if (data.accessToken) { + const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload; + exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms - return userDto; + this.logger.info('Set Cookie ddf-auth'); + res.cookie('ddf-auth', data.accessToken, { + domain: '.dadosfera.ai', + maxAge: exp, + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); } + + if (data.refreshToken) { + this.logger.info('Set Cookie ddf-refresh-auth'); + res.cookie('ddf-refresh-auth', data.refreshToken, { + domain: '.dadosfera.ai', + maxAge: exp, + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + } + + if (data.userId) { + this.logger.info('Set Cookie ddf-refresh-auth'); + res.cookie('ddf-user-id', data.userId, { + domain: '.dadosfera.ai', + maxAge: exp, + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + } + } + + public cleanUpAuthSession(res: Response) { + const exp = 1000 * 60 * 3; + + res.cookie('ddf-auth', '', { + domain: 'dadosfera.ai', + maxAge: Date.now() - exp, + expires: new Date(), + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + + res.cookie('ddf-refresh-auth', '', { + domain: 'dadosfera.ai', + maxAge: Date.now() - exp, + expires: new Date(), + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + + this.logger.info('Clean cookie sessions'); + } + + private async validateJwtToken(token: string) { + const decoded: any = token && jwt.decode(token, { complete: true }); + if (!decoded) throw new Error('Invalid token'); + + const { kid } = decoded.header; + // Busca a chave pública + const { keys } = await this.getPublicKeys(); + const pemValue = keys.find((k) => k.kid === kid)?.pem; + if (!pemValue) throw new Error('Public key not found'); + jwt.verify(token, pemValue); + + return decoded.payload; + } + + private async getUserfromPayload(payload: JwtPayload): Promise { + this.logger.info('getUser'); + + const metadata = PackTheMetadata({ + customer_id: payload.customer_id, + }); + + const { user } = await lastValueFrom( + this.userService.UserFindOneById({ id: payload.user_id }, metadata), + ); + + const userDto: UserDTO = { + id: user.id, + name: user.username, + jobTitle: user?.jobTitle || null, + department: user?.department || null, + hierarchy: user?.hierarchy || null, + customer: { + id: payload.customer_id, + name: payload.customer_name, + tier: payload.customer_tier, + }, + }; + + return userDto; + } + + private validateResourceAccess(origin: string, user: UserDTO) { + this.logger.info( + "Validate whether the source URL is a resource belonging to the user's client", + ); + this.logger.info('Origin: ' + origin); + this.logger.info('Customer: ' + user.customer.name); + + const urlParts = origin.replace('https://', '').split('.'); + const domain = urlParts[0]; + const isResouceStg = urlParts[1] === 'stg'; + + const domainParts = domain.split('-'); + const customerInDomain = domainParts[domainParts.length - 1]; + + if (isResouceStg && process.env.ENV !== 'stg') { + throw new ForbiddenException( + `Customer ${user.customer.name} cannot access ${origin}`, + ); + } + + if (customerInDomain != user.customer.name) { + throw new ForbiddenException( + `Customer ${user.customer.name} cannot access ${origin}`, + ); + } + + return; + } }