gogole botao

This commit is contained in:
armando 2026-09-15 21:38:41 -03:00
parent ebd666b361
commit ffabc734f6
11 changed files with 379 additions and 12 deletions

View File

@ -17,5 +17,10 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
DEMO_OPERATOR_EMAIL=ursula.b@example.com
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
NEXT_PUBLIC_API_URL=http://localhost:3001/api

90
api/package-lock.json generated
View File

@ -7,6 +7,7 @@
"": {
"name": "api",
"version": "0.0.1",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.1",
@ -24,6 +25,7 @@
"dotenv": "^16.6.1",
"ioredis": "^5.6.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@ -39,6 +41,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.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",
"eslint": "^9.18.0",
@ -3755,6 +3758,16 @@
"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": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@ -3765,6 +3778,18 @@
"@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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
@ -3776,6 +3801,18 @@
"@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": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
@ -5041,6 +5078,15 @@
],
"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": {
"version": "2.11.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
@ -9139,6 +9185,12 @@
"devOptional": true,
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9354,6 +9406,18 @@
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
@ -9364,6 +9428,26 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@ -11175,6 +11259,12 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",

View File

@ -28,21 +28,22 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^12.0.0",
"@prisma/client": "^6.19.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.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",
"dotenv": "^16.6.1",
"ioredis": "^5.6.1",
"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": {
"@eslint/eslintrc": "^3.2.0",
@ -53,10 +54,10 @@
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/passport-jwt": "^4.0.1",
"tsx": "^4.20.5",
"@types/jest": "^30.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",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@ -71,6 +72,7 @@
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.20.5",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},

View File

@ -28,6 +28,10 @@ model PlatformUser {
name String
email String @unique
passwordHash String
googleId String? @unique
githubId String? @unique
totpSecret String?
totpEnabled Boolean @default(false)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -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 passport from 'passport';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
import type { AuthUser } from './auth.types';
import { readOAuthState, signOAuthState } from './oauth-state';
import type { GoogleIdentity } from './strategies/google.strategy';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
constructor(
private readonly auth: AuthService,
private readonly config: ConfigService,
) {}
@Post('login')
@HttpCode(200)
@ -23,6 +30,43 @@ export class AuthController {
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')
@HttpCode(200)
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
@ -49,4 +93,40 @@ export class AuthController {
me(@CurrentUser() user: AuthUser) {
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}`;
}
}

View File

@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
@Module({
imports: [
@ -18,7 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, JwtStrategy, GoogleStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -15,7 +15,9 @@ import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url';
import type { AuthUser, JwtPayload } from './auth.types';
import type { LoginDto } from './dto/login.dto';
import type { GoogleIdentity } from './strategies/google.strategy';
import { permissionsFor, ROLE_LABEL } from './roles';
import { type OAuthState, safeNext } from './oauth-state';
const LOGIN_WINDOW_SECONDS = 15 * 60;
const LOGIN_MAX_ATTEMPTS = 10;
@ -41,6 +43,25 @@ export class AuthService {
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) {
if (!rawToken) {
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);
}
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) {
const payload: JwtPayload = {
sub: id,

View 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');
});
});

View 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;
}

View 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