feat(api-keys): Fehlversuchs-Sperre je IP und Log ohne Schlüssel

Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen,
abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch
für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie
der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher
des Pods und ist auf 10.000 Adressen begrenzt.

Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx)
und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den
SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt,
widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:43:56 +02:00
1 parent d7f0d2c13b
commit 1a1e70c94b
4 files changed
+408 -9

No files matched your search

@@ -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<string, unknown> = {}) {
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();
});
});
@@ -1,5 +1,7 @@
import { import {
ForbiddenException, ForbiddenException,
HttpException,
HttpStatus,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
@@ -13,6 +15,7 @@ import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] { function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [ return [
@@ -30,6 +33,25 @@ const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each // lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit. // read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000; 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. */ /** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal { export interface ApiKeyPrincipal {
@@ -67,6 +89,7 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable() @Injectable()
export class ApiKeysService { export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name); private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor( constructor(
private readonly prisma: PrismaService, 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 * 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( async authenticate(
token: string, token: string,
clientIp?: string, clientIp?: string,
): Promise<ApiKeyPrincipal | null> { ): Promise<ApiKeyPrincipal | null> {
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)) { if (!TOKEN_PATTERN.test(token)) {
return null; return { reason: 'malformed' };
} }
const apiKey = await this.prisma.apiKey.findUnique({ const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) }, where: { keyHash: hashToken(token) },
}); });
const now = new Date(); const now = new Date();
if ( if (!apiKey) {
!apiKey || return { reason: 'unknown' };
apiKey.revokedAt || }
(apiKey.expiresAt && apiKey.expiresAt <= now) if (apiKey.revokedAt) {
) { return { reason: 'revoked' };
return null; }
if (apiKey.expiresAt && apiKey.expiresAt <= now) {
return { reason: 'expired' };
} }
if (!isIpAllowed(clientIp, apiKey.allowedIps)) { if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a // 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 // Intersect with today's allowlist: narrowing it later also narrows
// keys issued before. // keys issued before.
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) }; return {
principal: { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) },
};
} }
} }
@@ -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);
});
});
@@ -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<string, Window>();
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;
}
}
}
}