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

@@ -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) },
};
}
}