sinka/api/src/auth/auth.service.ts
2026-09-15 21:10:42 -03:00

223 lines
7.1 KiB
TypeScript

import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
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 { permissionsFor, ROLE_LABEL } from './roles';
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);
}
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() },
});
}
profile(user: AuthUser) {
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,
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 UnauthorizedException('Credenciais inválidas.');
}
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 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: 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: 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);
}