qwen_2.5_model / src /modules /auth /guards /api-key.guard.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
5.54 kB
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { AuthService } from '../auth.service';
import { ApiKeyRole } from '../entities/api-key.entity';
import { REQUIRED_ROLE_KEY, PUBLIC_KEY, SESSION_SCOPED_KEY } from '../decorators/auth.decorators';
import { resolveClientIp } from '../../../common/utils/ip';
import { setRequestActor } from '../../../common/services/request-context';
import { AuditService } from '../../audit/audit.service';
import { AuditAction } from '../../audit/entities/audit-log.entity';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
private readonly configService: ConfigService,
private readonly auditService: AuditService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [context.getHandler(), context.getClass()]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
try {
return await this.authorize(request, context);
} catch (err) {
// Record rejected/denied authentication attempts so the audit log has a forensic trail for
// credential probing. Fire-and-forget: audit logging is best-effort and must never turn a
// 401/403 into a failure of the guard itself.
if (err instanceof UnauthorizedException || err instanceof ForbiddenException) {
// Stamp at least the IP so the failed-auth audit row below is attributable even though the
// key was never resolved. setRequestActor is a no-op outside a request scope.
setRequestActor({ ipAddress: this.getClientIp(request) });
void this.auditService.logWarn(AuditAction.API_KEY_AUTH_FAILED, {
ipAddress: this.getClientIp(request),
method: request.method,
path: request.path,
errorMessage: err.message,
});
}
throw err;
}
}
private async authorize(request: Request, context: ExecutionContext): Promise<boolean> {
const apiKeyHeader = this.extractApiKey(request);
if (!apiKeyHeader) {
throw new UnauthorizedException('API key is required');
}
const requiredRole = this.reflector.getAllAndOverride<ApiKeyRole>(REQUIRED_ROLE_KEY, [
context.getHandler(),
context.getClass(),
]);
// Resolve the session id used for the key's allowedSessions scope. `:sessionId` is always a
// session; the bare `:id` param is only a session on controllers marked @SessionScoped (i.e.
// SessionController) — on other routes `:id` is an unrelated resource id (API key, plugin, …)
// and must NOT be fed to the allowedSessions check, which would spuriously deny a scoped key.
const sessionScoped = this.reflector.getAllAndOverride<boolean>(SESSION_SCOPED_KEY, [
context.getHandler(),
context.getClass(),
]);
const sessionId = (request.params['sessionId'] || (sessionScoped ? request.params['id'] : undefined)) as
string | undefined;
const clientIp = this.getClientIp(request);
// Validate API key
const apiKey = await this.authService.validateApiKey(apiKeyHeader, clientIp, sessionId);
if (requiredRole && !this.authService.hasPermission(apiKey, requiredRole)) {
throw new ForbiddenException(`Insufficient permissions. Required: ${requiredRole}`);
}
// Attach API key to request for use in controllers
(request as Request & { apiKey: typeof apiKey }).apiKey = apiKey;
// Expose the trusted-proxy-aware client IP so controllers (e.g. the audit trail on key lifecycle
// ops) reuse the already-resolved value instead of re-deriving it.
(request as Request & { clientIp?: string }).clientIp = clientIp;
// Stamp the resolved actor into the per-request async context so downstream audit log writes —
// which fire from services deep in the call stack without DI access to the key — can attribute
// the action to this key + IP. Without this every audit row's apiKey/ipAddress column is blank
// because call sites pass only { sessionId } etc.
setRequestActor({ apiKeyId: apiKey.id, apiKeyName: apiKey.name, ipAddress: clientIp });
return true;
}
private extractApiKey(request: Request): string | undefined {
// Support both X-API-Key header and Authorization Bearer
const xApiKey = request.headers['x-api-key'] as string;
if (xApiKey) return xApiKey;
const authHeader = request.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return undefined;
}
/**
* Resolve the real client IP used for the API key's allowedIps whitelist.
*
* X-Forwarded-For is client-controllable, so it is only honored when the
* request actually arrives from a configured trusted proxy (TRUSTED_PROXIES).
* With no trusted proxies configured, the header is ignored entirely and the
* direct socket address is used — preventing IP-whitelist spoofing.
*/
private getClientIp(request: Request): string {
const trustedProxies = this.configService.get<string[]>('security.trustedProxies') ?? [];
return resolveClientIp(request, trustedProxies);
}
}