diff --git a/README.md b/README.md index 7693260..ccb4f5b 100644 --- a/README.md +++ b/README.md @@ -299,4 +299,3 @@ Some technologies used in this project: ## ⚙️ Back-end Architecture The architecture can be found at [this link](https://sites.google.com/dadosfera.ai/wikidoproduto/time/back-end). - diff --git a/docsfera.json b/docsfera.json index a26017e..ac32457 100644 --- a/docsfera.json +++ b/docsfera.json @@ -301,6 +301,71 @@ ] } }, + "/auth/session/{session}": { + "get": { + "operationId": "AuthController_getSession", + "parameters": [ + { + "name": "session", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/auth/oauth/google": { + "get": { + "operationId": "AuthController_googleOauth", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "tags": [ + "Auth" + ] + } + }, + "/auth/oauth/google/callback": { + "get": { + "operationId": "AuthController_googleOauthCallback", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, "/connectors": { "post": { "operationId": "ConnectorController_uploadConnector", diff --git a/package-lock.json b/package-lock.json index 49a0c80..8cae407 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "^3.112.0", "@dadosfera/dadosfera-logs": "^1.0.0-beta.4", - "@dadosfera/protospack-v2": "3.28.0", + "@dadosfera/protospack-v2": "3.29.0", "@grpc/grpc-js": "^1.6.7", "@grpc/proto-loader": "^0.6.13", "@nestjs/common": "^8.4.7", @@ -1727,9 +1727,9 @@ } }, "node_modules/@dadosfera/protospack-v2": { - "version": "3.28.0", - "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.28.0.tgz", - "integrity": "sha512-JZYYhoaXFUpb5W/fBVt4XYe1Hlef2x9aLlM2Yv0erYn9QAu+/Pb99YnXbkgawQVlmJojSRrAvSwq1o9yOgKCrg==", + "version": "3.29.0", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.29.0.tgz", + "integrity": "sha512-G66u9V+/+5rQb+fpJdDXydTaji2Mlr0Ro5TTYcQleITKU9DjF1WZs/jP/Qu0Y224nCb8HhePvaVl/Rr0wtLt4w==", "dependencies": { "@grpc/grpc-js": "^1.6.7", "rxjs": "^7.5.5", @@ -12284,9 +12284,9 @@ } }, "@dadosfera/protospack-v2": { - "version": "3.28.0", - "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.28.0.tgz", - "integrity": "sha512-JZYYhoaXFUpb5W/fBVt4XYe1Hlef2x9aLlM2Yv0erYn9QAu+/Pb99YnXbkgawQVlmJojSRrAvSwq1o9yOgKCrg==", + "version": "3.29.0", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.29.0.tgz", + "integrity": "sha512-G66u9V+/+5rQb+fpJdDXydTaji2Mlr0Ro5TTYcQleITKU9DjF1WZs/jP/Qu0Y224nCb8HhePvaVl/Rr0wtLt4w==", "requires": { "@grpc/grpc-js": "^1.6.7", "rxjs": "^7.5.5", diff --git a/package.json b/package.json index 6525a77..4eaa62a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "^3.112.0", "@dadosfera/dadosfera-logs": "^1.0.0-beta.4", - "@dadosfera/protospack-v2": "3.28.0", + "@dadosfera/protospack-v2": "3.29.0", "@grpc/grpc-js": "^1.6.7", "@grpc/proto-loader": "^0.6.13", "@nestjs/common": "^8.4.7", diff --git a/src/modules/auth/README.md b/src/modules/auth/README.md new file mode 100644 index 0000000..02268dd --- /dev/null +++ b/src/modules/auth/README.md @@ -0,0 +1,38 @@ +# Auth + +## SSO/oAuth + +### Strategy +We are using [PassportJs](https://www.passportjs.org/) to handle oAuth authentications. + +When the client (front end) makes a `GET /auth/oauth/{strategy}` Passport automatically redirects the user to the `strategy` login page. To do that we must configure and use a **Passport Strategy**. We must also have a callback route, conventionally `GET /auth/oauth/{strategy}/callback`, so the oAuth app can report the status of the user's login. + +- If the oAuth is successfull we call DUC's `AuthOauthSignIn` request that gets the tokens from Cognito and saves them on cache temporarily under a key we call `session`. Duc returns that `session` to maestro which then redirects the user to our app login page with that `session` as a query param. + +- If the oAuth login is not successfull for some reason or the user **does not** exist on DUC's database we redirect the user to our login page with an `error` and `error_description` as query params. + +### Routes + +So in order to have an SSO login, besides configuring the Strategy, we must have two routes for each Strategy, like in the example below: + +```ts + @Get('oauth/google') + @UseGuards(AuthGuard('google-login')) + googleOauth() { + this.logger.info('/oauth/google'); + return true; + } + + @Get('oauth/google/callback') + @UseGuards(AuthGuard('google-login')) + @Redirect() + async googleOauthCallback(@Req() req) { + const { url, email, token, language = 'pt-br' } = await this.callback(req); + if (url.searchParams.get('error')) { + this.logger.error('/oauth/google - ERROR'); + return { url: url.href }; + } + // ... Rest of the logic + return { url: url.href }; + } +``` \ No newline at end of file diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 1fb66e7..01868c1 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,6 +8,10 @@ import { Inject, UseFilters, Get, + UseGuards, + Redirect, + Req, + Param, } from '@nestjs/common'; import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { @@ -35,12 +39,17 @@ import { AuthSignInRes, } from './dtos/login'; import { PackTheMetadata } from 'src/utils/ PackTheMetadata'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; +import ErrorCodes, { OauthErrors } from 'src/utils/errorCodes'; +import jwt from 'jsonwebtoken'; @ApiTags('Auth') @UseFilters(new GrpcToHttpExceptionFilter()) @Controller('auth') export class AuthController { logger: DadosferaLogger; + redirectUrl: string; constructor( @Inject(DadosferaLogger) @@ -48,6 +57,17 @@ export class AuthController { private authClient: AuthClientService, ) { this.logger = dadosferaLogger.logger; + + switch (process.env.ENV) { + case 'stg': + this.redirectUrl = `https://app.${process.env.ENV}.dadosfera.ai/auth/login`; + break; + case 'prd': + this.redirectUrl = `https://app.dadosfera.ai/auth/login`; + break; + default: + this.redirectUrl = `http://localhost:4200/auth/login`; + } } @Post('sign-in') @@ -205,8 +225,99 @@ export class AuthController { @Authenticated() @ApiSecurity('access-token') @Get('verify-access-token') - @HttpCode(HttpStatus.OK) verifyAccessToken() { return { access_token_status: 'valid' }; } + + @Get('session/:session') + getSession(@Param('session') session: string) { + return this.authClient.getSession(session); + } + + @Get('oauth/google') + @UseGuards(AuthGuard('google-login')) + googleOauth() { + this.logger.info('/oauth/google'); + return true; + } + + @Get('oauth/google/callback') + @UseGuards(AuthGuard('google-login')) + @Redirect() + async googleOauthCallback(@Req() req) { + const { url, email, token, language = 'pt-br' } = await this.callback(req); + if (url.searchParams.get('error')) { + this.logger.error('/oauth/google - ERROR'); + return { url: url.href }; + } + + await this.authClient + .oauthSignIn({ + username: email, + token, + }) + .then(({ session }) => { + this.logger.info('/oauth/google - SUCESS'); + url.searchParams.set('session', session); + }) + .catch((err) => { + this.logger.error('/oauth/google - ERROR'); + let error = OauthErrors.INVALID_CREDENTIALS[language].error; + let error_description = + OauthErrors.INVALID_CREDENTIALS[language].error_description; + switch (err.details) { + case ErrorCodes.USER.NOT_FOUND: + error = OauthErrors.USER_NOT_FOUND[language].error; + error_description = + OauthErrors.USER_NOT_FOUND[language].error_description(email); + break; + case ErrorCodes.AUTH.UNAUTHORIZED: + error = OauthErrors.INVALID_SESSION[language].error; + error_description = + OauthErrors.INVALID_SESSION[language].error_description; + break; + } + url.searchParams.set('error', error); + url.searchParams.set('error_description', error_description); + return null; + }); + return { url: url.href }; + } + + async callback(req: Request) { + const { error, state } = req.query; + const { authInfo } = req; + const url = new URL(this.redirectUrl); + let email, token, error_title, error_description; + let language: 'pt-br' | 'en-us' = 'pt-br'; + + const stateObject = jwt.verify( + state as string, + process.env.JWT_PRIVATE_KEY, + ); + if (typeof stateObject != 'string') language = stateObject.language; + + if (error || !authInfo) { + this.logger.error(error); + if (!authInfo) this.logger.error('No authInfo', { request: req }); + + error_title = OauthErrors.INVALID_CREDENTIALS[language].error; + error_description = + OauthErrors.INVALID_CREDENTIALS[language].error_description; + if (error) error_description += ` - [${error}]`; + } else { + const { accessToken } = authInfo as any; + const { _json: userInfo } = req.user as any; + + email = userInfo.email; + token = accessToken; + } + + if (error_title) { + url.searchParams.set('error', error_title); + url.searchParams.set('error_description', error_description); + } + + return { token, email, url, language }; + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1f6c3f4..24e7623 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -6,12 +6,19 @@ import { AuthController } from './auth.controller'; import { AuthClientService } from './auth.service'; import { DucClient } from '../duc/client.config'; +import { GoogleLoginStrategy } from './passport-strategies/google-strategy'; +import { getOauthSecrets } from 'src/utils/OauthSecrets'; const client = new DucClient(); @Module({ imports: [ClientsModule.register([client.providerOptions])], controllers: [AuthController], - providers: [AuthClientService, DadosferaLogger], + providers: [ + AuthClientService, + DadosferaLogger, + GoogleLoginStrategy, + { provide: 'OAUTH_SECRETS', useValue: getOauthSecrets() }, + ], exports: [AuthClientService], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8a69efe..6db5c8c 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -157,4 +157,13 @@ export class AuthClientService implements OnModuleInit { this.authService.AuthVerifyTotpMfa({ accessToken, totp }), ); } + + async getSession(session: string) { + return lastValueFrom(this.authService.AuthGetSession({ session })); + } + + async oauthSignIn(data: { username: string; token: string }) { + const { username, token } = data; + return lastValueFrom(this.authService.AuthOauthSignIn({ token, username })); + } } diff --git a/src/modules/auth/passport-strategies/google-strategy.ts b/src/modules/auth/passport-strategies/google-strategy.ts new file mode 100644 index 0000000..2af25cd --- /dev/null +++ b/src/modules/auth/passport-strategies/google-strategy.ts @@ -0,0 +1,46 @@ +import { + AuthenticateOptionsGoogle, + Profile, + Strategy, + StrategyOptions, +} from 'passport-google-oauth20'; +import { PassportStrategy } from '@nestjs/passport'; +import { Inject, Injectable } from '@nestjs/common'; +import { OauthSecrets } from 'src/utils/OauthSecrets'; +import { Request } from 'express'; +import jwt from 'jsonwebtoken'; + +@Injectable() +export class GoogleLoginStrategy extends PassportStrategy( + Strategy, + 'google-login', +) { + redirect_uri: string; + constructor( + @Inject('OAUTH_SECRETS') + private readonly oauthSecrets: OauthSecrets, + ) { + const options: StrategyOptions = { + clientID: oauthSecrets['google-login'].client_id, + clientSecret: oauthSecrets['google-login'].client_secret, + callbackURL: oauthSecrets['google-login'].redirect_uri, + scope: ['email', 'profile', 'openid'], + }; + const verify = ( + accessToken: string, + refreshToken: string, + profile: Profile, + done, + ) => { + return done(null, profile, { accessToken, refreshToken }); + }; + + super(options, verify); + } + + authenticate(req: Request, options: AuthenticateOptionsGoogle) { + const language = req.headers['dadosfera-lang'] || req.query.language; + options.state = jwt.sign({ language }, process.env.JWT_PRIVATE_KEY); + super.authenticate(req, options); + } +} diff --git a/src/modules/catalog/catalog-client.ts b/src/modules/catalog/catalog-client.ts index bd58bdb..5d6208c 100644 --- a/src/modules/catalog/catalog-client.ts +++ b/src/modules/catalog/catalog-client.ts @@ -6,6 +6,8 @@ import { import { credentials } from '@grpc/grpc-js'; import { Catalog } from '@dadosfera/protospack-v2'; +const isLocalConnection = !!process.env.PIFACTORY_URL?.includes('0.0.0.0'); + export class CatalogClientConfiguration { public name = 'CatalogClientConfiguration'; private config: GrpcOptions = { @@ -16,10 +18,7 @@ export class CatalogClientConfiguration { Catalog.ProtoPackages.ReadPackage, Catalog.ProtoPackages.WritePackage, ], - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [ Catalog.ProtoPaths.ReadFilePath, Catalog.ProtoPaths.WriteFilePath, diff --git a/src/modules/connection-test/connection-test-client.config.ts b/src/modules/connection-test/connection-test-client.config.ts index 4ef28bd..6c042c9 100644 --- a/src/modules/connection-test/connection-test-client.config.ts +++ b/src/modules/connection-test/connection-test-client.config.ts @@ -6,14 +6,15 @@ import { } from '@nestjs/microservices'; import { ConnectionTest } from '@dadosfera/protospack-v2'; +const isLocalConnection = !!process.env.INFACTORY_URL?.includes('0.0.0.0'); + export class ConnectionTestClientConfiguration { private config: GrpcOptions = { transport: Transport.GRPC, options: { url: process.env.INFACTORY_URL, package: [ConnectionTest.ProtoPackages.ReadPackage], - credentials: - process.env.ENV === 'local' ? undefined : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [ConnectionTest.ProtoPaths.ReadFilePath], loader: { keepCase: true, diff --git a/src/modules/connection/client.config.ts b/src/modules/connection/client.config.ts index 8f1cbb4..938ef37 100644 --- a/src/modules/connection/client.config.ts +++ b/src/modules/connection/client.config.ts @@ -6,6 +6,8 @@ import { } from '@nestjs/microservices'; import { ConnectionManager } from '@dadosfera/protospack-v2'; +const isLocalConnection = !!process.env.INFACTORY_URL?.includes('0.0.0.0'); + export class ConnectionClientConfiguration { public name = 'ConnectionClientConfiguration'; private config: GrpcOptions = { @@ -16,10 +18,7 @@ export class ConnectionClientConfiguration { ConnectionManager.ProtoPackages.WritePackage, ConnectionManager.ProtoPackages.ReadPackage, ], - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [ ConnectionManager.ProtoPaths.WriteFilePath, ConnectionManager.ProtoPaths.ReadFilePath, diff --git a/src/modules/connector/client.config.ts b/src/modules/connector/client.config.ts index 711f265..fdbd5a1 100644 --- a/src/modules/connector/client.config.ts +++ b/src/modules/connector/client.config.ts @@ -6,6 +6,8 @@ import { } from '@nestjs/microservices'; import { ConnectorManager } from '@dadosfera/protospack-v2'; +const isLocalConnection = !!process.env.INFACTORY_URL?.includes('0.0.0.0'); + export class ConnectorClientConfiguration { public name = 'ConnectorClientConfiguration'; private config: GrpcOptions = { @@ -16,10 +18,7 @@ export class ConnectorClientConfiguration { ConnectorManager.ProtoPackages.WritePackage, ConnectorManager.ProtoPackages.ReadPackage, ], - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [ ConnectorManager.ProtoPaths.WriteFilePath, ConnectorManager.ProtoPaths.ReadFilePath, diff --git a/src/modules/permissions/permissions.service.ts b/src/modules/permissions/permissions.service.ts index f972a6b..f5a03a1 100644 --- a/src/modules/permissions/permissions.service.ts +++ b/src/modules/permissions/permissions.service.ts @@ -44,8 +44,11 @@ export class PermissionsService ); } - // internally used to send permissions to duc on microservice startup async onApplicationBootstrap() { + // Do not sent permissions if running local but pointing to a remote DUC url + if (process.env.ENV == 'local' && !process.env.DUC_URL.includes('0.0.0.0')) + return; + this.logger.info('sending permissions to DUC...'); const permissions = Object.values(PERMISSIONS_GROUPS).flatMap((namespace) => diff --git a/src/modules/pipelines/pipelines-client.ts b/src/modules/pipelines/pipelines-client.ts index 1359ed5..a7055e7 100644 --- a/src/modules/pipelines/pipelines-client.ts +++ b/src/modules/pipelines/pipelines-client.ts @@ -6,6 +6,8 @@ import { import { PipelinePackages, PipelineProtoFilePath } from 'protospack'; import { credentials } from '@grpc/grpc-js'; +const isLocalConnection = !!process.env.PIFACTORY_URL?.includes('0.0.0.0'); + export class PipelinesClientConfiguration { public name = 'PipelinesClientConfiguration'; private config: GrpcOptions = { @@ -13,10 +15,7 @@ export class PipelinesClientConfiguration { options: { url: process.env.PIFACTORY_URL, package: PipelinePackages, - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: PipelineProtoFilePath, loader: { keepCase: true, diff --git a/src/modules/pipelinesV2/pipelines-client.ts b/src/modules/pipelinesV2/pipelines-client.ts index 0ddce5a..118e890 100644 --- a/src/modules/pipelinesV2/pipelines-client.ts +++ b/src/modules/pipelinesV2/pipelines-client.ts @@ -9,6 +9,8 @@ import { } from '@dadosfera/protospack-v2/dist/lib/PipelineV2'; import { credentials } from '@grpc/grpc-js'; +const isLocalConnection = !!process.env.PIFACTORY_URL?.includes('0.0.0.0'); + export class PipelinesClientConfiguration { public name = 'PipelinesClientConfiguration'; private config: GrpcOptions = { @@ -16,10 +18,7 @@ export class PipelinesClientConfiguration { options: { url: process.env.PIFACTORY_URL, package: [ProtoPackages.ReadPackage, ProtoPackages.WritePackage], - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [ProtoPaths.ReadFilePath, ProtoPaths.WriteFilePath], loader: { keepCase: true, diff --git a/src/modules/transformations/transformations-client.ts b/src/modules/transformations/transformations-client.ts index 9be60ee..9b9a14d 100644 --- a/src/modules/transformations/transformations-client.ts +++ b/src/modules/transformations/transformations-client.ts @@ -6,6 +6,8 @@ import { import { credentials } from '@grpc/grpc-js'; import { Transformation } from '@dadosfera/protospack-v2'; +const isLocalConnection = !!process.env.INFACTORY_URL?.includes('0.0.0.0'); + export class TransformationsClientConfiguration { public name = 'TransformationsClientConfiguration'; private config: GrpcOptions = { @@ -13,10 +15,7 @@ export class TransformationsClientConfiguration { options: { url: process.env.INFACTORY_URL, package: [Transformation.ProtoPackages.WritePackage], - credentials: - process.env.LOCAL_ENV || process.env.ENV === 'local' - ? undefined - : credentials.createSsl(), + credentials: isLocalConnection ? undefined : credentials.createSsl(), protoPath: [Transformation.ProtoPaths.WriteFilePath], loader: { enums: String, diff --git a/src/utils/OauthSecrets.ts b/src/utils/OauthSecrets.ts index d882c76..28b9f62 100644 --- a/src/utils/OauthSecrets.ts +++ b/src/utils/OauthSecrets.ts @@ -2,6 +2,7 @@ import { SecretsManagerClient, GetSecretValueCommand, } from '@aws-sdk/client-secrets-manager'; +let cachedSecrets: OauthSecrets; class OauthSecretsObject { client_id = ''; @@ -15,8 +16,10 @@ export class OauthSecrets { mailchimp = new OauthSecretsObject(); facebook = new OauthSecretsObject(); salesforce = new OauthSecretsObject(); + 'google-login' = new OauthSecretsObject(); } export async function getOauthSecrets() { + if (cachedSecrets) return cachedSecrets; const secrets = new OauthSecrets(); const path = process.env.SM_OAUTH_PATH; const secretsManagerClient = new SecretsManagerClient({}); @@ -37,5 +40,6 @@ export async function getOauthSecrets() { }; } } + cachedSecrets = secrets; return secrets; } diff --git a/src/utils/errorCodes.ts b/src/utils/errorCodes.ts index 19a9d22..871f006 100644 --- a/src/utils/errorCodes.ts +++ b/src/utils/errorCodes.ts @@ -89,3 +89,37 @@ const ErrorCodes = { }; export default ErrorCodes; + +export const OauthErrors = { + INVALID_CREDENTIALS: { + 'pt-br': { + error: 'Erro ao autorizar', + error_description: 'Credenciais inválidas. Tente novamente', + }, + 'en-us': { + error: 'Authorization Error', + error_description: 'Invalid credentials. Please try again', + }, + }, + USER_NOT_FOUND: { + 'pt-br': { + error: 'Erro ao autorizar', + error_description: (email) => `Usuário não existe na Dadosfera: ${email}`, + }, + 'en-us': { + error: 'Authorization Error', + error_description: (email) => + `User does not exist on Dadosfera: ${email}`, + }, + }, + INVALID_SESSION: { + 'pt-br': { + error: 'Erro ao autorizar', + error_description: 'Sessão inválida!', + }, + 'en-us': { + error: 'Authorization Error', + error_description: 'Invalid session!', + }, + }, +};