Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
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 { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
|
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
|
|
import {
|
|
API_KEY_FAILURE_LIMIT,
|
|
FailedAttemptLimiter,
|
|
} from './failed-attempt-limiter';
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stand-in for the shared counter table with the same counting rule as
|
|
* PrismaThrottlerStorage within one window: count up to the limit, the hit
|
|
* after it blocks, a blocked source keeps its count. Time and the database
|
|
* itself are covered by the e2e tests.
|
|
*/
|
|
function inMemoryStorage(): PrismaThrottlerStorage {
|
|
const rows = new Map<string, { hits: number; blocked: boolean }>();
|
|
return {
|
|
increment: (
|
|
key: string,
|
|
ttl: number,
|
|
limit: number,
|
|
blockDuration: number,
|
|
throttlerName: string,
|
|
) => {
|
|
const id = `${throttlerName}|${key}`;
|
|
const row = rows.get(id) ?? { hits: 0, blocked: false };
|
|
const next = row.blocked
|
|
? row
|
|
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
|
|
rows.set(id, next);
|
|
return Promise.resolve({
|
|
totalHits: next.hits,
|
|
timeToExpire: ttl / 1000,
|
|
isBlocked: next.blocked,
|
|
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
|
|
});
|
|
},
|
|
} as unknown as PrismaThrottlerStorage;
|
|
}
|
|
|
|
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,
|
|
new FailedAttemptLimiter(inMemoryStorage()),
|
|
);
|
|
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);
|
|
});
|
|
|
|
async function failFrom(ip: string, times: number): Promise<void> {
|
|
for (let i = 0; i < times; i++) {
|
|
// Unbekannter, aber richtig aufgebauter Schlüssel.
|
|
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
|
|
}
|
|
}
|
|
|
|
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
|
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
|
|
|
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
|
await expect(blocked).rejects.toBeInstanceOf(HttpException);
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
expect(loggedText()).toContain('203.0.113.7');
|
|
|
|
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
|
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
|
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
|
);
|
|
await expect(
|
|
service.authenticate(TOKEN, '203.0.113.7'),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
|
|
// Andere Adressen sind nicht betroffen.
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
|
|
).resolves.toBe(null);
|
|
});
|
|
|
|
it('meldet das Erreichen der Grenze einmal im Log', async () => {
|
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
|
|
const limitMessages = warn.mock.calls.filter(([message]) =>
|
|
String(message).includes('get 429'),
|
|
);
|
|
expect(limitMessages).toHaveLength(1);
|
|
});
|
|
|
|
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
|
|
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
|
|
id: 'key-1',
|
|
scopes: ['erp-inventory:read'],
|
|
});
|
|
});
|
|
|
|
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
|
|
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
|
|
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
|
|
null,
|
|
);
|
|
}
|
|
// Weder gesperrt noch eine Datenbankabfrage.
|
|
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
|
).resolves.toBe(null);
|
|
});
|
|
|
|
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
|
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
|
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
|
await service.authenticate(
|
|
OTHER_TOKEN,
|
|
`2001:db8:1:2::${i.toString(16)}`,
|
|
);
|
|
}
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
// Ein anderes /64 ist frei.
|
|
await expect(
|
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
|
|
).resolves.toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('ApiKeysService.create', () => {
|
|
let service: ApiKeysService;
|
|
let prisma: { apiKey: { create: jest.Mock } };
|
|
|
|
beforeEach(() => {
|
|
prisma = {
|
|
apiKey: {
|
|
create: jest.fn((args: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
|
|
),
|
|
},
|
|
};
|
|
const permissionResolver = {
|
|
getPermissions: jest
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
|
|
),
|
|
};
|
|
service = new ApiKeysService(
|
|
prisma as unknown as PrismaService,
|
|
{ record: jest.fn() } as unknown as AuditLogService,
|
|
permissionResolver as unknown as PermissionResolverService,
|
|
new FailedAttemptLimiter(inMemoryStorage()),
|
|
);
|
|
});
|
|
|
|
it('refuses a new key with both shop scopes', async () => {
|
|
await expect(
|
|
service.create(
|
|
{
|
|
name: 'Shop',
|
|
scopes: ['shop-sync:read', 'shop-sync:write'],
|
|
},
|
|
'user-1',
|
|
),
|
|
).rejects.toThrow(
|
|
new BadRequestException(
|
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
|
),
|
|
);
|
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses the read-only ERP scope together with booking', async () => {
|
|
await expect(
|
|
service.create(
|
|
{
|
|
name: 'ERPNext',
|
|
scopes: ['erp-inventory:read', 'shop-sync:write'],
|
|
},
|
|
'user-1',
|
|
),
|
|
).rejects.toThrow(
|
|
new BadRequestException(
|
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
|
),
|
|
);
|
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
|
await expect(
|
|
service.create(
|
|
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
|
'user-1',
|
|
),
|
|
).rejects.toThrow(
|
|
new BadRequestException(
|
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
|
),
|
|
);
|
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats the same scope given twice as one', async () => {
|
|
const created = await service.create(
|
|
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
|
'user-1',
|
|
);
|
|
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
|
});
|
|
|
|
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
|
'still creates a key with only %s',
|
|
async (scope) => {
|
|
const created = await service.create(
|
|
{ name: 'Shop', scopes: [scope] },
|
|
'user-1',
|
|
);
|
|
expect(created.apiKey.scopes).toEqual([scope]);
|
|
},
|
|
);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|