import { ForbiddenException, HttpException, HttpStatus, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { createHash, randomBytes, randomInt } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { ApiKey } from '../../generated/prisma'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { ApiKeyScope, isApiKeyScope } from './api-key-scopes'; import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { FailedAttemptLimiter } from './failed-attempt-limiter'; import { rateLimitSubject } from '../common/throttler/rate-limit-subject'; function normalizeAllowlist(entries: readonly string[] | undefined): string[] { return [ ...new Set( (entries ?? []) .map(normalizeAllowlistEntry) .filter((entry): entry is string => entry !== null), ), ]; } // ack_<8 lowercase chars>_<32 random bytes, base64url> const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/; const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; // lastUsedAt is informational; writing it on every request would turn each // read into a write for no benefit. const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000; // "ack_" plus the 8-character prefix: identifies a key in logs without // revealing its secret part. const LOGGABLE_PREFIX_LENGTH = 12; /** * Failed key attempts per client IP before that IP is refused with 429 for * the rest of the window - the same budget as the shop's ERP export. A key * holds 256 random bits, so this is not what keeps it safe; it keeps a * misconfigured or hostile client from hammering the key lookup, and the * block applies to every key from that address, so a correct key cannot be * told apart from a wrong one while it lasts. */ export const API_KEY_FAILURE_LIMIT = { maxFailures: 20, windowMs: 15 * 60 * 1000, maxTrackedSources: 10_000, } as const; type RejectReason = 'malformed' | 'unknown' | 'revoked' | 'expired'; /** What a request authenticated by an API key may do - it is not a user. */ export interface ApiKeyPrincipal { id: string; scopes: ApiKeyScope[]; } /** An API key as shown in the admin UI - never the hash or the key itself. */ export type ApiKeySummary = Omit; export interface CreatedApiKey { apiKey: ApiKeySummary; /** The only time the plain key exists outside the caller's system. */ token: string; } function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } function randomPrefix(): string { let prefix = ''; for (let i = 0; i < 8; i++) { prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)]; } return `ack_${prefix}`; } function toSummary(apiKey: ApiKey): ApiKeySummary { const { keyHash, ...summary } = apiKey; void keyHash; return summary; } @Injectable() export class ApiKeysService { private readonly logger = new Logger(ApiKeysService.name); private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT); constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} async findAll(): Promise { const keys = await this.prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' }, }); return keys.map(toSummary); } async create( dto: CreateApiKeyDto, actingUserId: string, ): Promise { // Nobody hands a machine more than they may do themselves. const callerPermissions = await this.permissionResolver.getPermissions(actingUserId); const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope)); if (missing.length > 0) { throw new ForbiddenException( `Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`, ); } const prefix = randomPrefix(); // 32 random bytes: a plain hash of it is safe to store without a slow // KDF - there is nothing guessable to brute-force. const token = `${prefix}_${randomBytes(32).toString('base64url')}`; const expiresAt = dto.expiresInDays !== undefined ? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000) : null; const apiKey = await this.prisma.apiKey.create({ data: { name: dto.name.trim(), prefix, keyHash: hashToken(token), scopes: [...new Set(dto.scopes)], allowedIps: normalizeAllowlist(dto.allowedIps), expiresAt, createdByUserId: actingUserId, }, }); const summary = toSummary(apiKey); await this.auditLog.record({ entityType: 'ApiKey', entityId: apiKey.id, action: 'CREATE', userId: actingUserId, afterData: summary, }); return { apiKey: summary, token }; } /** Changes where a key may be used from, without issuing a new one. */ async updateAllowedIps( id: string, dto: UpdateApiKeyDto, actingUserId: string, ): Promise { const before = await this.prisma.apiKey.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('API-Schlüssel nicht gefunden'); } const updated = await this.prisma.apiKey.update({ where: { id }, data: { allowedIps: normalizeAllowlist(dto.allowedIps) }, }); await this.auditLog.record({ entityType: 'ApiKey', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: toSummary(before), afterData: toSummary(updated), }); return toSummary(updated); } async revoke(id: string, actingUserId: string): Promise { const before = await this.prisma.apiKey.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('API-Schlüssel nicht gefunden'); } if (before.revokedAt) { return toSummary(before); } const revoked = await this.prisma.apiKey.update({ where: { id }, data: { revokedAt: new Date() }, }); await this.auditLog.record({ entityType: 'ApiKey', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: toSummary(before), afterData: toSummary(revoked), }); return toSummary(revoked); } /** * Resolves a presented key to what it may do, or null for anything that * must not pass: malformed, unknown, revoked or expired. Each of those * counts as a failed attempt of the client IP; an IP over the limit gets * 429 before any lookup. * * The key is found by the SHA-256 of the presented token through a unique * index - never by comparing secrets byte by byte, so response timing says * nothing about the stored key. */ async authenticate( token: string, clientIp?: string, ): Promise { const source = clientIp ?? 'unknown'; // Counted per client network like the throttler (IPv6 /64), so rotating // addresses inside one's own prefix does not reset the budget. const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown'; if (this.failures.isBlocked(subject)) { // Same body shape as the built-in 4xx exceptions (JSON object, not a // bare string), so clients can read `message` uniformly. throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, error: 'Too Many Requests', message: 'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen', }, HttpStatus.TOO_MANY_REQUESTS, ); } const result = await this.resolve(token, clientIp); if ('principal' in result) { return result.principal; } // Only the public prefix is ever logged, never the key itself. const prefix = result.reason === 'malformed' ? '(malformed)' : token.slice(0, LOGGABLE_PREFIX_LENGTH); this.logger.warn( `API key rejected (${result.reason}): ${prefix} from ${source}`, ); if (this.failures.recordFailure(subject)) { this.logger.warn( `API key attempts from ${subject} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`, ); } return null; } private async resolve( token: string, clientIp: string | undefined, ): Promise<{ principal: ApiKeyPrincipal } | { reason: RejectReason }> { if (!TOKEN_PATTERN.test(token)) { return { reason: 'malformed' }; } const apiKey = await this.prisma.apiKey.findUnique({ where: { keyHash: hashToken(token) }, }); const now = new Date(); if (!apiKey) { return { reason: 'unknown' }; } if (apiKey.revokedAt) { return { reason: 'revoked' }; } if (apiKey.expiresAt && apiKey.expiresAt <= now) { return { reason: 'expired' }; } if (!isIpAllowed(clientIp, apiKey.allowedIps)) { // The key itself is right: say so, so the admin finds the cause - a // thief learns nothing new, they already hold the key. this.logger.warn( `API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`, ); throw new ForbiddenException( 'API-Schlüssel ist für diese IP-Adresse nicht freigegeben', ); } if ( !apiKey.lastUsedAt || now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS || apiKey.lastUsedIp !== (clientIp ?? null) ) { await this.prisma.apiKey.update({ where: { id: apiKey.id }, data: { lastUsedAt: now, lastUsedIp: clientIp ?? null }, }); } // Intersect with today's allowlist: narrowing it later also narrows // keys issued before. return { principal: { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) }, }; } } /** The key from an `Authorization: Bearer ack_…` header, or null. */ export function extractApiKeyToken( authorization: string | undefined, ): string | null { if (!authorization?.startsWith('Bearer ack_')) { return null; } return authorization.slice('Bearer '.length).trim(); }