322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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<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();
|
|
}
|
|
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);
|
|
}
|