Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
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,
|
|
combinesReadAndWrite,
|
|
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 {
|
|
API_KEY_FAILURE_LIMIT,
|
|
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;
|
|
|
|
type RejectReason = '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);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
|
private readonly failures: FailedAttemptLimiter,
|
|
) {}
|
|
|
|
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> {
|
|
// One scope per new key: a leaked read key cannot book, a leaked booking
|
|
// key cannot read, and each connected system gets its own key.
|
|
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
|
if (combinesReadAndWrite(dto.scopes)) {
|
|
throw new BadRequestException(
|
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
|
);
|
|
}
|
|
if (new Set(dto.scopes).size > 1) {
|
|
throw new BadRequestException(
|
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
|
);
|
|
}
|
|
// 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.
|
|
*
|
|
* 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. Order matters: the lookup comes first, and
|
|
* only an invalid key counts against (or is stopped by) the failure limit
|
|
* of the client network. A malformed token is not a guess at a key and is
|
|
* refused without lookup and without counting.
|
|
*/
|
|
async authenticate(
|
|
token: string,
|
|
clientIp?: string,
|
|
): Promise<ApiKeyPrincipal | null> {
|
|
if (!TOKEN_PATTERN.test(token)) {
|
|
return null;
|
|
}
|
|
const apiKey = await this.prisma.apiKey.findUnique({
|
|
where: { keyHash: hashToken(token) },
|
|
});
|
|
const now = new Date();
|
|
const reason = rejectReason(apiKey, now);
|
|
if (reason === null && apiKey) {
|
|
return this.accept(apiKey, clientIp, now);
|
|
}
|
|
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
|
return null;
|
|
}
|
|
|
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
|
private async recordRejection(
|
|
token: string,
|
|
reason: RejectReason,
|
|
clientIp: string | undefined,
|
|
): Promise<void> {
|
|
// 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';
|
|
const verdict = await this.failures.recordFailure(subject);
|
|
if (verdict.blocked) {
|
|
// 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,
|
|
);
|
|
}
|
|
// Only the public prefix is ever logged, never the key itself.
|
|
this.logger.warn(
|
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
|
);
|
|
if (verdict.limitReached) {
|
|
this.logger.warn(
|
|
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async accept(
|
|
apiKey: ApiKey,
|
|
clientIp: string | undefined,
|
|
now: Date,
|
|
): Promise<ApiKeyPrincipal> {
|
|
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 { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
|
|
}
|
|
}
|
|
|
|
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
|
|
if (!apiKey) return 'unknown';
|
|
if (apiKey.revokedAt) return 'revoked';
|
|
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
|
|
return null;
|
|
}
|
|
|
|
/** 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();
|
|
}
|