Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import {
|
|
ForbiddenException,
|
|
HttpException,
|
|
HttpStatus,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { ApiKey } from '../../generated/prisma';
|
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
|
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';
|
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
|
|
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
|
return [
|
|
...new Set(
|
|
(entries ?? [])
|
|
.map(normalizeAllowlistEntry)
|
|
.filter((entry): entry is string => entry !== null),
|
|
),
|
|
];
|
|
}
|
|
|
|
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
|
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
|
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 {
|
|
id: string;
|
|
scopes: ApiKeyScope[];
|
|
}
|
|
|
|
/** An API key as shown in the admin UI - never the hash or the key itself. */
|
|
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
|
|
|
|
export interface CreatedApiKey {
|
|
apiKey: ApiKeySummary;
|
|
/** The only time the plain key exists outside the caller's system. */
|
|
token: string;
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function randomPrefix(): string {
|
|
let prefix = '';
|
|
for (let i = 0; i < 8; i++) {
|
|
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
|
|
}
|
|
return `ack_${prefix}`;
|
|
}
|
|
|
|
function toSummary(apiKey: ApiKey): ApiKeySummary {
|
|
const { keyHash, ...summary } = apiKey;
|
|
void keyHash;
|
|
return summary;
|
|
}
|
|
|
|
@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,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
) {}
|
|
|
|
async findAll(): Promise<ApiKeySummary[]> {
|
|
const keys = await this.prisma.apiKey.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return keys.map(toSummary);
|
|
}
|
|
|
|
async create(
|
|
dto: CreateApiKeyDto,
|
|
actingUserId: string,
|
|
): Promise<CreatedApiKey> {
|
|
// Nobody hands a machine more than they may do themselves.
|
|
const callerPermissions =
|
|
await this.permissionResolver.getPermissions(actingUserId);
|
|
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
|
|
if (missing.length > 0) {
|
|
throw new ForbiddenException(
|
|
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
|
|
);
|
|
}
|
|
const prefix = randomPrefix();
|
|
// 32 random bytes: a plain hash of it is safe to store without a slow
|
|
// KDF - there is nothing guessable to brute-force.
|
|
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
|
|
const expiresAt =
|
|
dto.expiresInDays !== undefined
|
|
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
|
: null;
|
|
|
|
const apiKey = await this.prisma.apiKey.create({
|
|
data: {
|
|
name: dto.name.trim(),
|
|
prefix,
|
|
keyHash: hashToken(token),
|
|
scopes: [...new Set(dto.scopes)],
|
|
allowedIps: normalizeAllowlist(dto.allowedIps),
|
|
expiresAt,
|
|
createdByUserId: actingUserId,
|
|
},
|
|
});
|
|
const summary = toSummary(apiKey);
|
|
await this.auditLog.record({
|
|
entityType: 'ApiKey',
|
|
entityId: apiKey.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: summary,
|
|
});
|
|
return { apiKey: summary, token };
|
|
}
|
|
|
|
/** Changes where a key may be used from, without issuing a new one. */
|
|
async updateAllowedIps(
|
|
id: string,
|
|
dto: UpdateApiKeyDto,
|
|
actingUserId: string,
|
|
): Promise<ApiKeySummary> {
|
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
|
if (!before) {
|
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
|
}
|
|
const updated = await this.prisma.apiKey.update({
|
|
where: { id },
|
|
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'ApiKey',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: toSummary(before),
|
|
afterData: toSummary(updated),
|
|
});
|
|
return toSummary(updated);
|
|
}
|
|
|
|
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
|
if (!before) {
|
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
|
}
|
|
if (before.revokedAt) {
|
|
return toSummary(before);
|
|
}
|
|
const revoked = await this.prisma.apiKey.update({
|
|
where: { id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'ApiKey',
|
|
entityId: id,
|
|
action: 'DEACTIVATE',
|
|
userId: actingUserId,
|
|
beforeData: toSummary(before),
|
|
afterData: toSummary(revoked),
|
|
});
|
|
return toSummary(revoked);
|
|
}
|
|
|
|
/**
|
|
* Resolves a presented key to what it may do, or null for anything that
|
|
* 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';
|
|
// Counted per client network like the throttler (IPv6 /64), so rotating
|
|
// addresses inside one's own prefix does not reset the budget.
|
|
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
|
if (this.failures.isBlocked(subject)) {
|
|
// 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(subject)) {
|
|
this.logger.warn(
|
|
`API key attempts from ${subject} 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 { reason: 'malformed' };
|
|
}
|
|
const apiKey = await this.prisma.apiKey.findUnique({
|
|
where: { keyHash: hashToken(token) },
|
|
});
|
|
const now = new Date();
|
|
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
|
|
// thief learns nothing new, they already hold the key.
|
|
this.logger.warn(
|
|
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
|
|
);
|
|
throw new ForbiddenException(
|
|
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
|
|
);
|
|
}
|
|
if (
|
|
!apiKey.lastUsedAt ||
|
|
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
|
|
apiKey.lastUsedIp !== (clientIp ?? null)
|
|
) {
|
|
await this.prisma.apiKey.update({
|
|
where: { id: apiKey.id },
|
|
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
|
});
|
|
}
|
|
// Intersect with today's allowlist: narrowing it later also narrows
|
|
// keys issued before.
|
|
return {
|
|
principal: { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) },
|
|
};
|
|
}
|
|
}
|
|
|
|
/** The key from an `Authorization: Bearer ack_…` header, or null. */
|
|
export function extractApiKeyToken(
|
|
authorization: string | undefined,
|
|
): string | null {
|
|
if (!authorization?.startsWith('Bearer ack_')) {
|
|
return null;
|
|
}
|
|
return authorization.slice('Bearer '.length).trim();
|
|
}
|