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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user