feat: revocable read-only API keys for machine access
Integrations such as the Wabenhain shop sync no longer need a service account with a password (and without MFA). An admin creates a key under Verwaltung > Automatisierung > API-Schlüssel. - Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is shown exactly once. Optional expiry, "last used" timestamp, revocation takes effect immediately; create/revoke are audit-logged without the key. - A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard admits it only to routes whose permission is among its scopes and never to user-only routes. Scopes come from a short allowlist (shop-sync:read). - Managing keys requires the new permission api-keys:manage. - Keys are excluded from backups on purpose; a restore means issuing a new key. Additive migration (new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ede86c062a
commit
445fb3b94b
21 files changed
+848
-5
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import { Injectable, 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';
|
||||
|
||||
// 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;
|
||||
|
||||
/** 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 {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
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> {
|
||||
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)],
|
||||
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 };
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
async authenticate(token: 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();
|
||||
if (
|
||||
!apiKey ||
|
||||
apiKey.revokedAt ||
|
||||
(apiKey.expiresAt && apiKey.expiresAt <= now)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!apiKey.lastUsedAt ||
|
||||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS
|
||||
) {
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { lastUsedAt: now },
|
||||
});
|
||||
}
|
||||
// Intersect with today's allowlist: narrowing it later also narrows
|
||||
// keys issued before.
|
||||
return { 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();
|
||||
}
|
||||
Reference in new issue
Block a user