diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts new file mode 100644 index 0000000..4eda736 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts @@ -0,0 +1,170 @@ +import { ForbiddenException, HttpException, Logger } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { + API_KEY_FAILURE_LIMIT, + ApiKeysService, + extractApiKeyToken, +} from './api-keys.service'; + +// Synthetischer Schlüssel im echten Format - kein echter Zugang. +const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`; +const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`; + +function storedKey(overrides: Record = {}) { + return { + id: 'key-1', + name: 'ERPNext', + prefix: 'ack_abcd1234', + keyHash: createHash('sha256').update(TOKEN).digest('hex'), + scopes: ['erp-inventory:read'], + createdByUserId: 'user-1', + createdAt: new Date('2026-10-01T00:00:00.000Z'), + lastUsedAt: null, + lastUsedIp: null, + allowedIps: [], + expiresAt: null, + revokedAt: null, + ...overrides, + }; +} + +describe('ApiKeysService.authenticate', () => { + let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } }; + let service: ApiKeysService; + let warn: jest.SpyInstance; + + beforeEach(() => { + prisma = { + apiKey: { + findUnique: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue({}), + }, + }; + service = new ApiKeysService( + prisma as unknown as PrismaService, + { record: jest.fn() } as unknown as AuditLogService, + { getPermissions: jest.fn() } as unknown as PermissionResolverService, + ); + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + function loggedText(): string { + return JSON.stringify(warn.mock.calls); + } + + it('sucht den Schlüssel nur über seinen Hash', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey()); + + const principal = await service.authenticate(TOKEN, '203.0.113.7'); + + expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] }); + expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ + where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') }, + }); + }); + + it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => { + await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe( + null, + ); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + it('lehnt einen unbekannten Schlüssel ab', async () => { + await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( + null, + ); + }); + + it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce( + storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }), + ); + await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( + null, + ); + expect(loggedText()).toContain('revoked'); + }); + + it('lehnt einen abgelaufenen Schlüssel ab', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce( + storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }), + ); + await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( + null, + ); + expect(loggedText()).toContain('expired'); + }); + + it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce( + storedKey({ revokedAt: new Date() }), + ); + await service.authenticate(TOKEN, '203.0.113.7'); + await service.authenticate(OTHER_TOKEN, '203.0.113.7'); + + const text = loggedText(); + expect(text).not.toContain(TOKEN); + expect(text).not.toContain(OTHER_TOKEN); + expect(text).not.toContain('A'.repeat(43)); + expect(text).not.toContain('B'.repeat(43)); + expect(text).toContain('ack_abcd1234'); + }); + + it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce( + storedKey({ scopes: ['erp-inventory:read', 'users:create'] }), + ); + const principal = await service.authenticate(TOKEN, '203.0.113.7'); + expect(principal?.scopes).toEqual(['erp-inventory:read']); + }); + + it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => { + prisma.apiKey.findUnique.mockResolvedValueOnce( + storedKey({ allowedIps: ['198.51.100.0/24'] }), + ); + await expect( + service.authenticate(TOKEN, '203.0.113.7'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it(`sperrt eine Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429, ohne die Datenbank zu fragen`, async () => { + for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) { + await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe( + null, + ); + } + prisma.apiKey.findUnique.mockClear(); + prisma.apiKey.findUnique.mockResolvedValue(storedKey()); + + // Gesperrt ist gesperrt - auch ein richtiger Schlüssel verrät nichts mehr. + const blocked = service.authenticate(TOKEN, '203.0.113.7'); + await expect(blocked).rejects.toBeInstanceOf(HttpException); + await expect( + service.authenticate(TOKEN, '203.0.113.7'), + ).rejects.toMatchObject({ status: 429 }); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + expect(loggedText()).toContain('203.0.113.7'); + + // Andere Adressen sind nicht betroffen. + await expect(service.authenticate(TOKEN, '198.51.100.1')).resolves.toEqual({ + id: 'key-1', + scopes: ['erp-inventory:read'], + }); + }); +}); + +describe('extractApiKeyToken', () => { + it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => { + expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN); + expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull(); + expect(extractApiKeyToken(undefined)).toBeNull(); + }); +}); diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.ts b/ApiCore-Backend/src/api-keys/api-keys.service.ts index 0da0256..8336681 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.service.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.service.ts @@ -1,5 +1,7 @@ import { ForbiddenException, + HttpException, + HttpStatus, Injectable, Logger, NotFoundException, @@ -13,6 +15,7 @@ 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'; function normalizeAllowlist(entries: readonly string[] | undefined): string[] { return [ @@ -30,6 +33,25 @@ 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 { @@ -67,6 +89,7 @@ function toSummary(apiKey: ApiKey): ApiKeySummary { @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, @@ -175,25 +198,71 @@ export class ApiKeysService { /** * Resolves a presented key to what it may do, or null for anything that - * must not pass: malformed, unknown, revoked or expired. + * 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'; + if (this.failures.isBlocked(source)) { + // 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(source)) { + this.logger.warn( + `API key attempts from ${source} 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 null; + return { reason: 'malformed' }; } const apiKey = await this.prisma.apiKey.findUnique({ where: { keyHash: hashToken(token) }, }); const now = new Date(); - if ( - !apiKey || - apiKey.revokedAt || - (apiKey.expiresAt && apiKey.expiresAt <= now) - ) { - return null; + 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 @@ -217,7 +286,9 @@ export class ApiKeysService { } // Intersect with today's allowlist: narrowing it later also narrows // keys issued before. - return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) }; + return { + principal: { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) }, + }; } } diff --git a/ApiCore-Backend/src/api-keys/failed-attempt-limiter.spec.ts b/ApiCore-Backend/src/api-keys/failed-attempt-limiter.spec.ts new file mode 100644 index 0000000..bda3cfb --- /dev/null +++ b/ApiCore-Backend/src/api-keys/failed-attempt-limiter.spec.ts @@ -0,0 +1,77 @@ +import { FailedAttemptLimiter } from './failed-attempt-limiter'; + +describe('FailedAttemptLimiter', () => { + let now: number; + let limiter: FailedAttemptLimiter; + + beforeEach(() => { + now = 1_000_000; + limiter = new FailedAttemptLimiter({ + maxFailures: 3, + windowMs: 60_000, + maxTrackedSources: 100, + clock: () => now, + }); + }); + + it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => { + expect(limiter.recordFailure('203.0.113.7')).toBe(false); + expect(limiter.recordFailure('203.0.113.7')).toBe(false); + expect(limiter.isBlocked('203.0.113.7')).toBe(false); + + // Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre. + expect(limiter.recordFailure('203.0.113.7')).toBe(true); + expect(limiter.isBlocked('203.0.113.7')).toBe(true); + }); + + it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => { + limiter.recordFailure('203.0.113.7'); + limiter.recordFailure('203.0.113.7'); + expect(limiter.recordFailure('203.0.113.7')).toBe(true); + expect(limiter.recordFailure('203.0.113.7')).toBe(false); + }); + + it('trennt Quellen voneinander', () => { + for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7'); + expect(limiter.isBlocked('203.0.113.7')).toBe(true); + expect(limiter.isBlocked('198.51.100.1')).toBe(false); + }); + + it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => { + for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7'); + now += 60_001; + expect(limiter.isBlocked('203.0.113.7')).toBe(false); + // Neues Fenster: der nächste Fehlversuch zählt wieder ab eins. + expect(limiter.recordFailure('203.0.113.7')).toBe(false); + expect(limiter.isBlocked('203.0.113.7')).toBe(false); + }); + + it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => { + const small = new FailedAttemptLimiter({ + maxFailures: 1, + windowMs: 60_000, + maxTrackedSources: 2, + clock: () => now, + }); + small.recordFailure('a'); + small.recordFailure('b'); + now += 60_001; + // Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen. + small.recordFailure('c'); + expect(small.trackedSources()).toBe(1); + expect(small.isBlocked('c')).toBe(true); + }); + + it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => { + const small = new FailedAttemptLimiter({ + maxFailures: 5, + windowMs: 60_000, + maxTrackedSources: 2, + clock: () => now, + }); + small.recordFailure('a'); + small.recordFailure('b'); + small.recordFailure('c'); + expect(small.trackedSources()).toBe(2); + }); +}); diff --git a/ApiCore-Backend/src/api-keys/failed-attempt-limiter.ts b/ApiCore-Backend/src/api-keys/failed-attempt-limiter.ts new file mode 100644 index 0000000..1da300c --- /dev/null +++ b/ApiCore-Backend/src/api-keys/failed-attempt-limiter.ts @@ -0,0 +1,81 @@ +export interface FailedAttemptLimiterOptions { + /** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */ + maxFailures: number; + /** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */ + windowMs: number; + /** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */ + maxTrackedSources: number; + clock?: () => number; +} + +interface Window { + failures: number; + startedAt: number; +} + +/** + * Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen + * Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist. + * + * Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft + * mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn - + * das ist für eine Bremse gegen Durchprobieren ausreichend. + */ +export class FailedAttemptLimiter { + private readonly windows = new Map(); + private readonly clock: () => number; + + constructor(private readonly options: FailedAttemptLimiterOptions) { + this.clock = options.clock ?? Date.now; + } + + isBlocked(source: string): boolean { + const window = this.activeWindow(source); + return window !== undefined && window.failures >= this.options.maxFailures; + } + + /** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */ + recordFailure(source: string): boolean { + const now = this.clock(); + const current = this.activeWindow(source); + if (!current) { + this.makeRoom(now); + } + const failures = (current?.failures ?? 0) + 1; + this.windows.set(source, { + failures, + startedAt: current?.startedAt ?? now, + }); + return failures === this.options.maxFailures; + } + + trackedSources(): number { + return this.windows.size; + } + + private activeWindow(source: string): Window | undefined { + const window = this.windows.get(source); + if (window && this.clock() - window.startedAt > this.options.windowMs) { + this.windows.delete(source); + return undefined; + } + return window; + } + + private makeRoom(now: number): void { + if (this.windows.size < this.options.maxTrackedSources) return; + for (const [source, window] of this.windows) { + if (now - window.startedAt > this.options.windowMs) { + this.windows.delete(source); + } + } + // Immer noch voll: den ältesten Eintrag verdrängen (Map behält die + // Einfügereihenfolge). Lieber eine Zählung verlieren als unbegrenzt wachsen. + if (this.windows.size >= this.options.maxTrackedSources) { + for (const oldest of this.windows.keys()) { + this.windows.delete(oldest); + break; + } + } + } +}