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