import { BadRequestException, 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, combinesReadAndWrite, 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 { API_KEY_FAILURE_LIMIT, 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; type RejectReason = '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); constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, // Counters shared by all api pods (ThrottlerHit), not process memory. private readonly failures: FailedAttemptLimiter, ) {} async findAll(): Promise { const keys = await this.prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' }, }); return keys.map(toSummary); } async create( dto: CreateApiKeyDto, actingUserId: string, ): Promise { // One scope per new key: a leaked read key cannot book, a leaked booking // key cannot read, and each connected system gets its own key. // Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14). if (combinesReadAndWrite(dto.scopes)) { throw new BadRequestException( 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', ); } if (new Set(dto.scopes).size > 1) { throw new BadRequestException( 'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen', ); } // 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. * * 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. Order matters: the lookup comes first, and * only an invalid key counts against (or is stopped by) the failure limit * of the client network. A malformed token is not a guess at a key and is * refused without lookup and without counting. */ async authenticate( token: string, clientIp?: string, ): Promise { if (!TOKEN_PATTERN.test(token)) { return null; } const apiKey = await this.prisma.apiKey.findUnique({ where: { keyHash: hashToken(token) }, }); const now = new Date(); const reason = rejectReason(apiKey, now); if (reason === null && apiKey) { return this.accept(apiKey, clientIp, now); } await this.recordRejection(token, reason ?? 'unknown', clientIp); return null; } /** Counts an invalid key; 429 once the client network is over the limit. */ private async recordRejection( token: string, reason: RejectReason, clientIp: string | undefined, ): Promise { // 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'; const verdict = await this.failures.recordFailure(subject); if (verdict.blocked) { // 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, ); } // Only the public prefix is ever logged, never the key itself. this.logger.warn( `API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`, ); if (verdict.limitReached) { this.logger.warn( `Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`, ); } } private async accept( apiKey: ApiKey, clientIp: string | undefined, now: Date, ): Promise { 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 { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) }; } } function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null { if (!apiKey) return 'unknown'; if (apiKey.revokedAt) return 'revoked'; if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired'; return null; } /** 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(); }