feat: Inventar-Export für ERPNext (erp-inventory:read) #1
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 {
|
||||
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<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)) {
|
||||
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) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user