diff --git a/docsfera.json b/docsfera.json index 96612ac..4749daf 100644 --- a/docsfera.json +++ b/docsfera.json @@ -34,14 +34,7 @@ }, "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthSignInRes" - } - } - } + "description": "" } }, "tags": [ @@ -675,6 +668,33 @@ ] } }, + "/auth/me": { + "get": { + "operationId": "AuthController_getMe", + "parameters": [ + { + "name": "dadosfera-lang", + "in": "header", + "required": false, + "schema": { + "enum": [ + "pt-br", + "en-us" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, "/connections": { "post": { "operationId": "ConnectionController_createConnection", @@ -6476,181 +6496,6 @@ "password" ] }, - "AuthCustomer": { - "type": "object", - "properties": { - "modules": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "tier": { - "type": "string" - }, - "scheduleLimit": { - "type": "string" - }, - "links": { - "type": "array", - "items": { - "type": "string" - } - }, - "themeEnabled": { - "type": "boolean" - }, - "enforceMfa": { - "type": "boolean" - } - }, - "required": [ - "modules", - "id", - "name", - "displayName", - "tier", - "scheduleLimit", - "links", - "themeEnabled", - "enforceMfa" - ] - }, - "AuthUser": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "username": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "username", - "createdAt" - ] - }, - "AuthTokens": { - "type": "object", - "properties": { - "accessToken": { - "type": "string" - }, - "refreshToken": { - "type": "string" - }, - "termsOfUseToken": { - "type": "string" - }, - "idToken": { - "type": "string", - "deprecated": true - } - }, - "required": [ - "accessToken", - "refreshToken", - "idToken" - ] - }, - "TermsOfUse": { - "type": "object", - "properties": { - "version": { - "type": "number" - }, - "publicUrl": { - "type": "string" - }, - "enforceDate": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": [ - "version", - "publicUrl", - "enforceDate", - "createdAt" - ] - }, - "TermsOfUseStatus": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "pending", - "required", - "ok" - ] - }, - "lastSigned": { - "$ref": "#/components/schemas/TermsOfUse" - }, - "next": { - "$ref": "#/components/schemas/TermsOfUse" - } - }, - "required": [ - "status" - ] - }, - "AuthSignInRes": { - "type": "object", - "properties": { - "permissions": { - "type": "array", - "items": { - "type": "string" - } - }, - "mfaStatus": { - "type": "string", - "enum": [ - "pending", - "none", - "totp" - ] - }, - "customer": { - "$ref": "#/components/schemas/AuthCustomer" - }, - "user": { - "$ref": "#/components/schemas/AuthUser" - }, - "tokens": { - "$ref": "#/components/schemas/AuthTokens" - }, - "termsOfUse": { - "$ref": "#/components/schemas/TermsOfUseStatus" - } - }, - "required": [ - "permissions", - "mfaStatus" - ] - }, "AuthRefreshAccessTokenReq": { "type": "object", "properties": { diff --git a/package-lock.json b/package-lock.json index 2add2f4..e1b3830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "axios": "^0.27.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.7", "cron-parser": "^4.9.0", "csv": "^6.3.11", "dotenv": "^14.3.2", @@ -52,6 +53,7 @@ "swagger-ui-express": "^4.6.3" }, "devDependencies": { + "@types/cookie-parser": "^1.4.9", "@types/express": "^4.17.17", "@types/jest": "27.0.2", "@types/jsonwebtoken": "^8.5.9", @@ -3664,6 +3666,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -5740,6 +5752,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/package.json b/package.json index b59aa9c..8cc3e5a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "axios": "^0.27.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.7", "cron-parser": "^4.9.0", "csv": "^6.3.11", "dotenv": "^14.3.2", @@ -73,6 +74,7 @@ "multer": "1.4.5-lts.1" }, "devDependencies": { + "@types/cookie-parser": "^1.4.9", "@types/express": "^4.17.17", "@types/jest": "27.0.2", "@types/jsonwebtoken": "^8.5.9", diff --git a/src/main.ts b/src/main.ts index 6fd8445..ee5a1f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,8 @@ import { AppModule } from './app.module'; import { writeFileSync } from 'fs'; import { execSync } from 'child_process'; import { INestApplication } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; + async function bootstrap() { DadosferaLogger.setupLogger({ serviceName: 'maestro', @@ -23,9 +25,11 @@ async function bootstrap() { methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', preflightContinue: false, optionsSuccessStatus: 204, + credentials: true }, }); app.use(helmet()); + app.use(cookieParser(process.env.COOKIE_SECRET)); if (process.env.ENV === 'prd') { app.use('/catalog/register-dataset', json({ limit: '10mb' })); app.use('/catalog/register-dataset', urlencoded({ extended: true, limit: '10mb' })); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index edefd98..edc4dc0 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -12,6 +12,7 @@ import { Redirect, Req, Param, + Res, } from '@nestjs/common'; import { ApiHeaders, @@ -47,9 +48,9 @@ import { } from './dtos/login'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { AuthGuard } from '@nestjs/passport'; -import { Request } from 'express'; +import { Request, Response } from 'express'; import ErrorCodes, { OauthErrors } from 'src/utils/errorCodes'; -import jwt from 'jsonwebtoken'; +import jwt, { JwtPayload } from 'jsonwebtoken'; import { LanguageEnum } from 'src/utils/languages.enum'; import { Language } from 'src/decorators/language.decorator'; import { ApiInternalOnlyEndpoint } from 'src/decorators/swagger.decorator'; @@ -86,11 +87,20 @@ export class AuthController { async signIn( @Body() { username, password, totp }: AuthSignInReq, @Language() language: LanguageEnum, - ): Promise { - this.logger.info('/auth - SignIn'); - const metadata = PackTheMetadata({ language }); - this.logger.info('metadata: ' + JSON.stringify(metadata.toJSON())); - return this.authClient.signIn({ username, password, totp }, metadata); + @Res() res: Response, + ) { + try { + this.logger.info('/auth - SignIn'); + const metadata = PackTheMetadata({ language }); + this.logger.info('metadata: ' + JSON.stringify(metadata.toJSON())); + const data = await this.authClient.signIn({ username, password, totp }, metadata); + this.addTokenInCookie(res, data.tokens); + + return res.send(data); + } catch (error) { + this.logger.error('/auth - SignIn - ERROR', error); + } + } @Post('refresh-access-token') @@ -100,17 +110,23 @@ export class AuthController { @Body() body: AuthRefreshAccessTokenReq, @Language() language: LanguageEnum, @Headers('origin') origin: string, + @Res() res: Response, ) { this.logger.info('/auth - RefreshAccessToken'); const { refreshToken, customerName: customer_name } = body; - const frontHost = origin.replace(/^https?:\/\//, ''); + const metadata = PackTheMetadata({ language, customer_name, - custom_host: frontHost, }); - return this.authClient.refreshAccessToken({ refreshToken }, metadata); + const data = await this.authClient.refreshAccessToken({ refreshToken }, metadata); + + this.addTokenInCookie(res, { + accessToken: data.accessToken, + }); + + return res.send(data); } @ApiInternalOnlyEndpoint() @@ -418,4 +434,93 @@ export class AuthController { return this.authClient.resetUsers(body.users, metadata); } + + @Get('me') + async getMe(@Req() req: Request, @Res() res: Response) { + // Lê cookies + const accessToken = req.cookies['ddf-auth']; + const refreshToken = req.cookies['ddf-refresh-auth']; + + console.log(req.cookies) + let payload: any; + let userInfo: any = {}; + try { + // Decodifica e valida o JWT de acesso + const decoded: any = accessToken && jwt.decode(accessToken, { complete: true }); + if (!decoded) throw new Error('Invalid token'); + const { kid } = decoded.header; + // Busca a chave pública + const { keys } = await this.authClient.getPublicKeys(); + const pemValue = keys.find((k) => k.kid === kid)?.pem; + if (!pemValue) throw new Error('Public key not found'); + jwt.verify(accessToken, pemValue); + payload = decoded.payload; + userInfo = { + id: payload.user_id, + name: payload.username, + customer: { + id: payload.customer_id, + name: payload.customer_name, + tier: payload.customer_tier, + } + }; + return res.status(200).json(userInfo); + } catch (err) { + // Se access token inválido, tenta refresh + if (!refreshToken) { + return res.status(401).json({ error: 'Not authenticated' }); + } + try { + // Chama refreshAccessToken + const metadata = PackTheMetadata({}); + const data = await this.authClient.refreshAccessToken({ refreshToken }, metadata); + // Retorna novo access token e dados mínimos + this.addTokenInCookie(res, { + accessToken: data.accessToken, + }); + // Decodifica novo token + const decoded: any = jwt.decode(data.accessToken, { complete: true }); + const payload = decoded.payload; + userInfo = { + id: payload.user_id, + name: payload.username, + customer: { + id: payload.customer_id, + name: payload.customer_name, + tier: payload.customer_tier, + } + }; + return res.status(200).json(userInfo); + } catch (refreshErr) { + return res.status(401).json({ error: 'Not authenticated' }); + } + } + } + + private addTokenInCookie(res: Response, tokens: { accessToken?: string, refreshToken?: string }) { + let exp = 1000 * 60 * 5; // 5 minutes + + if (tokens.accessToken) { + const { exp: expiration } = jwt.decode(tokens.accessToken) as JwtPayload; + exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms + + res.cookie('ddf-auth', tokens.accessToken, { + domain: 'dadosfera.local', + maxAge: exp, + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + } + + if (tokens.refreshToken) { + res.cookie('ddf-refresh-auth', tokens.refreshToken, { + domain: 'dadosfera.local', + maxAge: exp, + httpOnly: true, + secure: true, + sameSite: 'none', // Necessário para cookies em requisições cross-site + }); + } + } }