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