97 lines
2.8 KiB
TypeScript
97 lines
2.8 KiB
TypeScript
import {
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { IntegrationScope } from '@prisma/client';
|
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
import { Request } from 'express';
|
|
import { REQUIRE_SCOPE_KEY } from '../decorators/require-scope.decorator';
|
|
import {
|
|
IntegrationApiKeyForGuard,
|
|
IntegrationApiKeysRepository,
|
|
} from '../../modules/integration-api-keys/integration-api-keys.repository';
|
|
|
|
export interface RequestWithApiKey extends Request {
|
|
apiKey?: IntegrationApiKeyForGuard;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ApiKeyGuard implements CanActivate {
|
|
private readonly logger = new Logger(ApiKeyGuard.name);
|
|
|
|
constructor(
|
|
private readonly reflector: Reflector,
|
|
private readonly repository: IntegrationApiKeysRepository,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const request = context.switchToHttp().getRequest<RequestWithApiKey>();
|
|
const headerValue = this.extractHeader(request);
|
|
|
|
if (!headerValue) {
|
|
throw new UnauthorizedException('API Key ausente');
|
|
}
|
|
|
|
const computedHash = createHash('sha256').update(headerValue).digest('hex');
|
|
const apiKey = await this.repository.findByHashedKey(computedHash);
|
|
|
|
if (!apiKey) {
|
|
throw new UnauthorizedException('API Key inválida');
|
|
}
|
|
|
|
if (!this.constantTimeEqualsHex(computedHash, apiKey.hashedKey)) {
|
|
throw new UnauthorizedException('API Key inválida');
|
|
}
|
|
|
|
if (!apiKey.isActive) {
|
|
throw new UnauthorizedException('API Key revogada');
|
|
}
|
|
|
|
if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) {
|
|
throw new UnauthorizedException('API Key expirada');
|
|
}
|
|
|
|
const requiredScope = this.reflector.getAllAndOverride<IntegrationScope | undefined>(
|
|
REQUIRE_SCOPE_KEY,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
|
|
if (!requiredScope) {
|
|
throw new ForbiddenException('Endpoint sem escopo declarado');
|
|
}
|
|
|
|
if (!apiKey.scopes.includes(requiredScope)) {
|
|
throw new ForbiddenException('Escopo insuficiente');
|
|
}
|
|
|
|
request.apiKey = apiKey;
|
|
|
|
void this.repository.touchLastUsed(apiKey.id).catch((err: unknown) => {
|
|
this.logger.warn(`Falha ao atualizar lastUsedAt: ${(err as Error).message}`);
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
private extractHeader(request: Request): string | null {
|
|
const raw = request.headers['x-api-key'];
|
|
if (!raw) return null;
|
|
if (Array.isArray(raw)) return raw[0] ?? null;
|
|
return raw;
|
|
}
|
|
|
|
private constantTimeEqualsHex(a: string, b: string): boolean {
|
|
if (a.length !== b.length) return false;
|
|
try {
|
|
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|