import { ForbiddenException, HttpException, HttpStatus, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { createHash, randomBytes } from 'node:crypto'; import * as bcrypt from 'bcrypt'; import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { RedisService } from '../redis/redis.service'; 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; @Injectable() export class AuthService { constructor( private readonly platform: PlatformPrismaService, private readonly tenants: TenantConnectionService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly redis: RedisService, ) {} async login(dto: LoginDto, ip?: string) { await this.assertRateLimit(dto.email, ip); const slug = dto.tenantSlug?.trim(); if (!slug || slug.toLowerCase() === 'platform') { return this.loginPlatform(dto.email, dto.password, ip); } return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip); } googleConfigured() { return Boolean(this.config.get('GOOGLE_CLIENT_ID') && this.config.get('GOOGLE_CLIENT_SECRET')); } webOrigin() { return this.config.get('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(); } const hashed = hashToken(rawToken); const platformToken = await this.platform.platformRefreshToken.findFirst({ where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } }, include: { user: true }, }); if (platformToken) { await this.platform.platformRefreshToken.update({ where: { id: platformToken.id }, data: { revokedAt: new Date() }, }); return this.issuePlatformSession(platformToken.user.id, platformToken.user.email, platformToken.user.name); } const tenants = await this.platform.tenant.findMany({ where: { status: 'ACTIVE' } }); for (const tenant of tenants) { const db = this.tenants.getByDatabase(tenant.database); const token = await db.refreshToken.findFirst({ where: { hashed, revokedAt: null, expiresAt: { gt: new Date() } }, include: { user: true }, }); if (!token) { continue; } await db.refreshToken.update({ where: { id: token.id }, data: { revokedAt: new Date() }, }); return this.issueTenantSession(tenant.id, tenant.slug, token.user.id, token.user.email, token.user.name, token.user.role); } throw new UnauthorizedException(); } async logout(rawToken: string | undefined, user: AuthUser) { if (!rawToken) { return; } const hashed = hashToken(rawToken); if (user.kind === 'platform') { await this.platform.platformRefreshToken.updateMany({ where: { hashed, revokedAt: null }, data: { revokedAt: new Date() }, }); return; } if (!user.tenantId) { return; } const db = await this.tenants.getByTenantId(user.tenantId); await db.refreshToken.updateMany({ where: { hashed, revokedAt: null }, data: { revokedAt: new Date() }, }); } async profile(user: AuthUser) { let tenantName: string | null = null; if (user.kind === 'tenant' && user.tenantId) { const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } }); tenantName = tenant?.name ?? null; } return { id: user.sub, name: user.name, email: user.email, role: user.role, roleLabel: ROLE_LABEL[user.role], kind: user.kind, tenantId: user.tenantId ?? null, tenantSlug: user.tenantSlug ?? null, tenantName, permissions: permissionsFor(user.role), }; } private async loginPlatform(email: string, password: string, ip?: string) { const user = await this.platform.platformUser.findUnique({ where: { email } }); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { throw new UnauthorizedException('Credenciais inválidas.'); } await this.platform.platformUser.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); await this.platform.platformAuditLog.create({ data: { userId: user.id, action: 'login', ip, entity: 'PlatformUser', entityId: user.id }, }); return this.issuePlatformSession(user.id, user.email, user.name); } private async loginTenant(slug: string, email: string, password: string, ip?: string) { const tenant = await this.platform.tenant.findUnique({ where: { slug } }); if (!tenant) { throw new NotFoundException('Este cliente não existe.'); } if (tenant.status !== 'ACTIVE') { throw new ForbiddenException('Empresa suspensa.'); } const db = this.tenants.getByDatabase(tenant.database); const user = await db.user.findUnique({ where: { email } }); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { throw new UnauthorizedException('Credenciais inválidas.'); } await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); await db.auditLog.create({ data: { userId: user.id, action: 'login', ip, entity: 'User', entityId: user.id }, }); 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) { throw new NotFoundException('Este cliente não existe.'); } if (tenant.status !== 'ACTIVE') { throw new ForbiddenException('Empresa suspensa.'); } 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, email, name, role: 'PLATFORM_ADMIN', }; const accessToken = await this.jwt.signAsync(payload); const refreshToken = rawRefreshToken(); await this.platform.platformRefreshToken.create({ data: { userId: id, hashed: hashToken(refreshToken), expiresAt: refreshExpiry(), }, }); return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) }; } private async issueTenantSession( tenantId: string, tenantSlug: string, id: string, email: string, name: string, role: JwtPayload['role'], ) { const payload: JwtPayload = { sub: id, email, name, role, tenantId, tenantSlug, }; const accessToken = await this.jwt.signAsync(payload); const refreshToken = rawRefreshToken(); const db = await this.tenants.getByTenantId(tenantId); await db.refreshToken.create({ data: { userId: id, hashed: hashToken(refreshToken), expiresAt: refreshExpiry(), }, }); return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) }; } private async assertRateLimit(email: string, ip?: string) { const key = `login:${ip ?? 'unknown'}:${email.toLowerCase()}`; const count = await this.redis.increment(key, LOGIN_WINDOW_SECONDS); if (count !== null && count > LOGIN_MAX_ATTEMPTS) { throw new HttpException('Muitas tentativas. Aguarde alguns minutos.', HttpStatus.TOO_MANY_REQUESTS); } } } function rawRefreshToken(): string { return randomBytes(48).toString('hex'); } function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } function refreshExpiry(): Date { return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); }