From 2bc060b13dcfa3734efe2e3a9fd2054a2fb69e71 Mon Sep 17 00:00:00 2001 From: Gabriel Rosa Date: Tue, 24 Jan 2023 19:19:00 -0300 Subject: [PATCH] FEAT: oauth sign in --- docsfera.json | 67 ++++++++++- package-lock.json | 14 +-- package.json | 2 +- src/modules/auth/auth.controller.ts | 107 +++++++++++++++++- src/modules/auth/auth.module.ts | 9 +- src/modules/auth/auth.service.ts | 9 ++ .../passport-strategies/google-strategy.ts | 46 ++++++++ src/utils/OauthSecrets.ts | 4 + src/utils/errorCodes.ts | 34 ++++++ 9 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 src/modules/auth/passport-strategies/google-strategy.ts diff --git a/docsfera.json b/docsfera.json index f509fa0..7bc5522 100644 --- a/docsfera.json +++ b/docsfera.json @@ -288,6 +288,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", @@ -3405,7 +3470,7 @@ } }, "info": { - "title": "Maestro - feature/upload-to-s3-presigned", + "title": "Maestro - feat/oauth-login", "description": "Documentation for Maestro gateway", "version": "1.0.0", "contact": {} diff --git a/package-lock.json b/package-lock.json index 49a0c80..b97f6b7 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-beta.1", "@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-beta.1", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.29.0-beta.1.tgz", + "integrity": "sha512-V9Fe1+sZ36tsTXUo6OZ97eqeJGdQb9y5f/7OiUgaKnYClzDH6s85+8SLLlpXM8c6KR5dZVeZGAjTRfw9ZV2/jg==", "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-beta.1", + "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.29.0-beta.1.tgz", + "integrity": "sha512-V9Fe1+sZ36tsTXUo6OZ97eqeJGdQb9y5f/7OiUgaKnYClzDH6s85+8SLLlpXM8c6KR5dZVeZGAjTRfw9ZV2/jg==", "requires": { "@grpc/grpc-js": "^1.6.7", "rxjs": "^7.5.5", diff --git a/package.json b/package.json index 6525a77..d491e9b 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-beta.1", "@grpc/grpc-js": "^1.6.7", "@grpc/proto-loader": "^0.6.13", "@nestjs/common": "^8.4.7", diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 2bf3915..8df8a58 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 { 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') @@ -204,8 +224,93 @@ export class AuthController { @Authenticated() @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() { + return true; + } + + @Get('oauth/google/callback') + @UseGuards(AuthGuard('google-login')) + @Redirect() + async googleOauthCallback(@Req() req) { + const { url, email, token, language } = await this.callback(req); + if (url.searchParams.get('error')) return { url: url.href }; + + await this.authClient + .oauthSignIn({ + username: email, + token, + }) + .then(({ session }) => { + url.searchParams.set('session', session); + }) + .catch((err) => { + 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..f290897 --- /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'].client_secret, + 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/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!', + }, + }, +};