gogole botao
This commit is contained in:
parent
ebd666b361
commit
ffabc734f6
@ -17,5 +17,10 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
|
|||||||
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
||||||
DEMO_OPERATOR_PASSWORD=SinkaOperador!1
|
DEMO_OPERATOR_PASSWORD=SinkaOperador!1
|
||||||
|
|
||||||
|
# OAuth Google (console: origem http://localhost:3000, callback abaixo)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
|||||||
90
api/package-lock.json
generated
90
api/package-lock.json
generated
@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "api",
|
"name": "api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
@ -24,6 +25,7 @@
|
|||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
@ -39,6 +41,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@ -3755,6 +3758,16 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/oauth": {
|
||||||
|
"version": "0.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
|
||||||
|
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/passport": {
|
"node_modules/@types/passport": {
|
||||||
"version": "1.0.17",
|
"version": "1.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||||
@ -3765,6 +3778,18 @@
|
|||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/passport-google-oauth20": {
|
||||||
|
"version": "2.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
|
||||||
|
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/passport": "*",
|
||||||
|
"@types/passport-oauth2": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/passport-jwt": {
|
"node_modules/@types/passport-jwt": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
@ -3776,6 +3801,18 @@
|
|||||||
"@types/passport-strategy": "*"
|
"@types/passport-strategy": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/passport-oauth2": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/oauth": "*",
|
||||||
|
"@types/passport": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/passport-strategy": {
|
"node_modules/@types/passport-strategy": {
|
||||||
"version": "0.2.38",
|
"version": "0.2.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||||
@ -5041,6 +5078,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.23",
|
"version": "2.11.23",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||||
@ -9139,6 +9185,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||||
|
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@ -9354,6 +9406,18 @@
|
|||||||
"url": "https://github.com/sponsors/jaredhanson"
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-google-oauth20": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-oauth2": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-jwt": {
|
"node_modules/passport-jwt": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
@ -9364,6 +9428,26 @@
|
|||||||
"passport-strategy": "^1.0.0"
|
"passport-strategy": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-oauth2": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64url": "3.x.x",
|
||||||
|
"oauth": "0.10.x",
|
||||||
|
"passport-strategy": "1.x.x",
|
||||||
|
"uid2": "0.0.x",
|
||||||
|
"utils-merge": "1.x.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-strategy": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
@ -11175,6 +11259,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid2": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uint8array-extras": {
|
"node_modules/uint8array-extras": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||||
|
|||||||
@ -28,21 +28,22 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.0",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/terminus": "^12.0.0",
|
"@nestjs/terminus": "^12.0.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"@nestjs/jwt": "^11.0.0",
|
|
||||||
"@nestjs/passport": "^11.0.5",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1"
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@ -53,10 +54,10 @@
|
|||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/cookie-parser": "^1.4.8",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"tsx": "^4.20.5",
|
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@ -71,6 +72,7 @@
|
|||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,10 @@ model PlatformUser {
|
|||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
googleId String? @unique
|
||||||
|
githubId String? @unique
|
||||||
|
totpSecret String?
|
||||||
|
totpEnabled Boolean @default(false)
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import passport from 'passport';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||||
import type { AuthUser } from './auth.types';
|
import type { AuthUser } from './auth.types';
|
||||||
|
import { readOAuthState, signOAuthState } from './oauth-state';
|
||||||
|
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly auth: AuthService) {}
|
constructor(
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ -23,6 +30,43 @@ export class AuthController {
|
|||||||
return { user: result.user };
|
return { user: result.user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('google')
|
||||||
|
googleStart(
|
||||||
|
@Query('tenant') tenant: string | undefined,
|
||||||
|
@Query('next') next: string | undefined,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
if (!this.auth.googleConfigured()) {
|
||||||
|
return response.redirect(this.failUrl(tenant, 'google_off'));
|
||||||
|
}
|
||||||
|
const kind = tenant ? 'tenant' : 'platform';
|
||||||
|
const state = signOAuthState(
|
||||||
|
{ kind, slug: tenant, next },
|
||||||
|
this.config.getOrThrow<string>('JWT_SECRET'),
|
||||||
|
);
|
||||||
|
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||||
|
url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'));
|
||||||
|
url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL'));
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', 'openid email profile');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('prompt', 'select_account');
|
||||||
|
return response.redirect(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('google/callback')
|
||||||
|
googleCallback(@Req() request: Request, @Res() response: Response) {
|
||||||
|
const rawState = String(request.query.state ?? '');
|
||||||
|
const tenant = this.slugFromState(rawState);
|
||||||
|
if (request.query.error) {
|
||||||
|
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => {
|
||||||
|
void this.finishGoogle(request, response, rawState, tenant, error, identity);
|
||||||
|
})(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
|
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
|
||||||
@ -49,4 +93,40 @@ export class AuthController {
|
|||||||
me(@CurrentUser() user: AuthUser) {
|
me(@CurrentUser() user: AuthUser) {
|
||||||
return this.auth.profile(user);
|
return this.auth.profile(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async finishGoogle(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
rawState: string,
|
||||||
|
tenant: string | undefined,
|
||||||
|
error: unknown,
|
||||||
|
identity: GoogleIdentity | false,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (error || !identity) {
|
||||||
|
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||||
|
}
|
||||||
|
const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET'));
|
||||||
|
const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip);
|
||||||
|
setAuthCookies(response, session.accessToken, session.refreshToken);
|
||||||
|
return response.redirect(redirect);
|
||||||
|
} catch (caught) {
|
||||||
|
const message = caught instanceof Error ? caught.message : '';
|
||||||
|
const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied';
|
||||||
|
return response.redirect(this.failUrl(tenant, code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private slugFromState(raw: string) {
|
||||||
|
try {
|
||||||
|
return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failUrl(tenant: string | undefined, code: string) {
|
||||||
|
const path = tenant ? `/${tenant}/login` : '/admin/login';
|
||||||
|
return `${this.auth.webOrigin()}${path}?error=${code}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { GoogleStrategy } from './strategies/google.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -18,7 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy, GoogleStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
|||||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||||
import type { AuthUser, JwtPayload } from './auth.types';
|
import type { AuthUser, JwtPayload } from './auth.types';
|
||||||
import type { LoginDto } from './dto/login.dto';
|
import type { LoginDto } from './dto/login.dto';
|
||||||
|
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||||
import { permissionsFor, ROLE_LABEL } from './roles';
|
import { permissionsFor, ROLE_LABEL } from './roles';
|
||||||
|
import { type OAuthState, safeNext } from './oauth-state';
|
||||||
|
|
||||||
const LOGIN_WINDOW_SECONDS = 15 * 60;
|
const LOGIN_WINDOW_SECONDS = 15 * 60;
|
||||||
const LOGIN_MAX_ATTEMPTS = 10;
|
const LOGIN_MAX_ATTEMPTS = 10;
|
||||||
@ -41,6 +43,25 @@ export class AuthService {
|
|||||||
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
|
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
googleConfigured() {
|
||||||
|
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
|
||||||
|
}
|
||||||
|
|
||||||
|
webOrigin() {
|
||||||
|
return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) {
|
||||||
|
await this.assertRateLimit(identity.email, ip);
|
||||||
|
if (state.kind === 'platform') {
|
||||||
|
const session = await this.loginPlatformGoogle(identity, ip);
|
||||||
|
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` };
|
||||||
|
}
|
||||||
|
const slug = normalizeSlug(state.slug ?? '');
|
||||||
|
const session = await this.loginTenantGoogle(slug, identity, ip);
|
||||||
|
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` };
|
||||||
|
}
|
||||||
|
|
||||||
async refresh(rawToken: string | undefined) {
|
async refresh(rawToken: string | undefined) {
|
||||||
if (!rawToken) {
|
if (!rawToken) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
@ -152,6 +173,74 @@ export class AuthService {
|
|||||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) {
|
||||||
|
const user =
|
||||||
|
(await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||||
|
(await this.platform.platformUser.findUnique({ where: { email: identity.email } }));
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.');
|
||||||
|
}
|
||||||
|
if (!user.googleId) {
|
||||||
|
await this.platform.platformUser.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { googleId: identity.googleId, lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.platform.platformUser.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.platform.platformAuditLog.create({
|
||||||
|
data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id },
|
||||||
|
});
|
||||||
|
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
|
||||||
|
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||||
|
if (!tenant || tenant.status !== 'ACTIVE') {
|
||||||
|
throw new UnauthorizedException('Credenciais inválidas.');
|
||||||
|
}
|
||||||
|
const db = this.tenants.getByDatabase(tenant.database);
|
||||||
|
let user =
|
||||||
|
(await db.user.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||||
|
(await db.user.findUnique({ where: { email: identity.email } }));
|
||||||
|
if (!user) {
|
||||||
|
const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||||
|
user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: identity.name,
|
||||||
|
email: identity.email,
|
||||||
|
googleId: identity.googleId,
|
||||||
|
passwordHash: await bcrypt.hash(issuedPassword, 10),
|
||||||
|
issuedPassword,
|
||||||
|
role: 'OPERATOR',
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'user.create',
|
||||||
|
ip,
|
||||||
|
entity: 'User',
|
||||||
|
entityId: user.id,
|
||||||
|
metadata: { via: 'google' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id },
|
||||||
|
});
|
||||||
|
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||||
|
}
|
||||||
|
|
||||||
private async issuePlatformSession(id: string, email: string, name: string) {
|
private async issuePlatformSession(id: string, email: string, name: string) {
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
sub: id,
|
sub: id,
|
||||||
|
|||||||
21
api/src/auth/oauth-state.spec.ts
Normal file
21
api/src/auth/oauth-state.spec.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { readOAuthState, safeNext, signOAuthState } from './oauth-state';
|
||||||
|
|
||||||
|
describe('oauth state', () => {
|
||||||
|
const secret = 'test-secret';
|
||||||
|
|
||||||
|
it('round-trips a signed tenant state', () => {
|
||||||
|
const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret);
|
||||||
|
expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered state', () => {
|
||||||
|
const token = signOAuthState({ kind: 'platform' }, secret);
|
||||||
|
expect(() => readOAuthState(`${token}x`, secret)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks open redirects', () => {
|
||||||
|
expect(safeNext('https://evil.test', '/admin')).toBe('/admin');
|
||||||
|
expect(safeNext('//evil.test', '/admin')).toBe('/admin');
|
||||||
|
expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes');
|
||||||
|
});
|
||||||
|
});
|
||||||
41
api/src/auth/oauth-state.ts
Normal file
41
api/src/auth/oauth-state.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export type OAuthState = {
|
||||||
|
kind: 'platform' | 'tenant';
|
||||||
|
slug?: string;
|
||||||
|
next?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function signOAuthState(payload: OAuthState, secret: string): string {
|
||||||
|
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url');
|
||||||
|
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOAuthState(raw: string | undefined, secret: string): OAuthState {
|
||||||
|
if (!raw || !raw.includes('.')) {
|
||||||
|
throw new Error('state');
|
||||||
|
}
|
||||||
|
const [body, sig] = raw.split('.');
|
||||||
|
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
throw new Error('state');
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number };
|
||||||
|
if (!parsed.exp || parsed.exp < Date.now()) {
|
||||||
|
throw new Error('state');
|
||||||
|
}
|
||||||
|
if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') {
|
||||||
|
throw new Error('state');
|
||||||
|
}
|
||||||
|
return { kind: parsed.kind, slug: parsed.slug, next: parsed.next };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeNext(raw: string | undefined, fallback: string): string {
|
||||||
|
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
34
api/src/auth/strategies/google.strategy.ts
Normal file
34
api/src/auth/strategies/google.strategy.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Strategy, type Profile } from 'passport-google-oauth20';
|
||||||
|
|
||||||
|
export type GoogleIdentity = {
|
||||||
|
googleId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super({
|
||||||
|
clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured',
|
||||||
|
clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured',
|
||||||
|
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback',
|
||||||
|
scope: ['email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
|
||||||
|
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||||
|
if (!email || !profile.id) {
|
||||||
|
throw new UnauthorizedException('Conta Google sem e-mail.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
googleId: profile.id,
|
||||||
|
email,
|
||||||
|
name: profile.displayName?.trim() || email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user