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();
});
});