feat(api-keys): restrict keys to source IP addresses

A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:41:46 +02:00
1 parent 9ae1d96a32
commit f1dc0de3e2
16 files changed
+463 -10

No files matched your search

@@ -0,0 +1,3 @@
-- API keys can be limited to source addresses (IP or CIDR).
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
+4
View File
@@ -147,6 +147,10 @@ model ApiKey {
createdByUser User? @relation(fields: [createdByUserId], references: [id]) createdByUser User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
lastUsedAt DateTime? lastUsedAt DateTime?
// Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist.
lastUsedIp String?
// Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall.
allowedIps String[] @default([])
expiresAt DateTime? expiresAt DateTime?
revokedAt DateTime? revokedAt DateTime?
@@ -1,9 +1,10 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { import {
ApiKeySummary, ApiKeySummary,
ApiKeysService, ApiKeysService,
@@ -31,6 +32,16 @@ export class ApiKeysController {
return this.apiKeys.create(dto, actor.id); return this.apiKeys.create(dto, actor.id);
} }
@RequirePermission('api-keys:manage')
@Patch(':id')
updateAllowedIps(
@Param('id') id: string,
@Body() dto: UpdateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
}
@RequirePermission('api-keys:manage') @RequirePermission('api-keys:manage')
@Post(':id/revoke') @Post(':id/revoke')
revoke( revoke(
@@ -1,10 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomBytes, randomInt } from 'node:crypto'; import { createHash, randomBytes, randomInt } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma'; import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes'; 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';
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> // ack_<8 lowercase chars>_<32 random bytes, base64url>
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/; const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
@@ -48,9 +66,12 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable() @Injectable()
export class ApiKeysService { export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {} ) {}
async findAll(): Promise<ApiKeySummary[]> { async findAll(): Promise<ApiKeySummary[]> {
@@ -64,6 +85,15 @@ export class ApiKeysService {
dto: CreateApiKeyDto, dto: CreateApiKeyDto,
actingUserId: string, actingUserId: string,
): Promise<CreatedApiKey> { ): 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(); const prefix = randomPrefix();
// 32 random bytes: a plain hash of it is safe to store without a slow // 32 random bytes: a plain hash of it is safe to store without a slow
// KDF - there is nothing guessable to brute-force. // KDF - there is nothing guessable to brute-force.
@@ -79,6 +109,7 @@ export class ApiKeysService {
prefix, prefix,
keyHash: hashToken(token), keyHash: hashToken(token),
scopes: [...new Set(dto.scopes)], scopes: [...new Set(dto.scopes)],
allowedIps: normalizeAllowlist(dto.allowedIps),
expiresAt, expiresAt,
createdByUserId: actingUserId, createdByUserId: actingUserId,
}, },
@@ -94,6 +125,31 @@ export class ApiKeysService {
return { apiKey: summary, token }; 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> { async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } }); const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) { if (!before) {
@@ -121,7 +177,10 @@ export class ApiKeysService {
* Resolves a presented key to what it may do, or null for anything that * 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.
*/ */
async authenticate(token: string): Promise<ApiKeyPrincipal | null> { async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
if (!TOKEN_PATTERN.test(token)) { if (!TOKEN_PATTERN.test(token)) {
return null; return null;
} }
@@ -136,13 +195,24 @@ export class ApiKeysService {
) { ) {
return null; return null;
} }
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 ( if (
!apiKey.lastUsedAt || !apiKey.lastUsedAt ||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
apiKey.lastUsedIp !== (clientIp ?? null)
) { ) {
await this.prisma.apiKey.update({ await this.prisma.apiKey.update({
where: { id: apiKey.id }, where: { id: apiKey.id },
data: { lastUsedAt: now }, data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
}); });
} }
// Intersect with today's allowlist: narrowing it later also narrows // Intersect with today's allowlist: narrowing it later also narrows
@@ -0,0 +1,17 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { normalizeAllowlistEntry } from '../ip-allowlist';
@ValidatorConstraint({ name: 'allowedIp' })
export class AllowedIpConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
}
defaultMessage(args: ValidationArguments): string {
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
}
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
ArrayMaxSize,
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
@@ -11,8 +12,11 @@ import {
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
Validate,
} from 'class-validator'; } from 'class-validator';
import { API_KEY_SCOPES } from '../api-key-scopes'; import { API_KEY_SCOPES } from '../api-key-scopes';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class CreateApiKeyDto { export class CreateApiKeyDto {
@ApiProperty({ @ApiProperty({
@@ -39,4 +43,16 @@ export class CreateApiKeyDto {
@Min(1) @Min(1)
@Max(730) @Max(730)
expiresInDays?: number; expiresInDays?: number;
@ApiPropertyOptional({
description:
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps?: string[];
} }
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class UpdateApiKeyDto {
@ApiProperty({
description:
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
type: [String],
})
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps!: string[];
}
@@ -0,0 +1,34 @@
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
describe('ip-allowlist', () => {
it('normalizes addresses and networks', () => {
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
});
it('rejects anything else', () => {
for (const entry of [
'',
'example.com',
'203.0.113.0/33',
'1.2.3.4/x',
'1.2.3.4/8/1',
]) {
expect(normalizeAllowlistEntry(entry)).toBeNull();
}
});
it('allows every address when the list is empty', () => {
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
});
it('allows only listed addresses and networks', () => {
const list = ['203.0.113.7', '198.51.100.0/24'];
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
expect(isIpAllowed(undefined, list)).toBe(false);
});
});
@@ -0,0 +1,66 @@
import * as net from 'node:net';
/** Max entries per key - an allowlist is a short list of servers. */
export const MAX_ALLOWED_IPS = 20;
function unmapIpv4(ip: string): string {
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
return mapped ? mapped[1] : ip;
}
/**
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
* null if the entry is neither an address nor a network.
*/
export function normalizeAllowlistEntry(entry: string): string | null {
const trimmed = entry.trim();
const [address, prefix, ...rest] = trimmed.split('/');
if (rest.length > 0 || !address) {
return null;
}
const ip = unmapIpv4(address);
const version = net.isIP(ip);
if (version === 0) {
return null;
}
if (prefix === undefined) {
return ip;
}
if (!/^\d{1,3}$/.test(prefix)) {
return null;
}
const bits = Number(prefix);
if (bits > (version === 4 ? 32 : 128)) {
return null;
}
return `${ip}/${bits}`;
}
/** An empty list allows every address. */
export function isIpAllowed(
clientIp: string | undefined,
allowedIps: readonly string[],
): boolean {
if (allowedIps.length === 0) {
return true;
}
if (!clientIp) {
return false;
}
const ip = unmapIpv4(clientIp);
const version = net.isIP(ip);
if (version === 0) {
return false;
}
const list = new net.BlockList();
for (const entry of allowedIps) {
const [address, prefix] = entry.split('/');
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
if (prefix === undefined) {
list.addAddress(address, family);
} else {
list.addSubnet(address, Number(prefix), family);
}
}
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
}
@@ -16,6 +16,7 @@ import {
interface RequestWithUser { interface RequestWithUser {
headers: { authorization?: string }; headers: { authorization?: string };
ip?: string;
user?: AuthenticatedUser; user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal; apiKey?: ApiKeyPrincipal;
} }
@@ -55,7 +56,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
// PermissionsGuard checks its scopes instead of a role. // PermissionsGuard checks its scopes instead of a role.
const apiKeyToken = extractApiKeyToken(request.headers.authorization); const apiKeyToken = extractApiKeyToken(request.headers.authorization);
if (apiKeyToken !== null) { if (apiKeyToken !== null) {
const principal = await this.apiKeys.authenticate(apiKeyToken); // request.ip honours TRUST_PROXY (see app.config.ts).
const principal = await this.apiKeys.authenticate(
apiKeyToken,
request.ip,
);
if (!principal) { if (!principal) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
+38
View File
@@ -190,4 +190,42 @@ describe('API keys (e2e)', () => {
.send({ name: 'x', scopes: ['shop-sync:read'] }) .send({ name: 'x', scopes: ['shop-sync:read'] })
.expect(403); .expect(403);
}); });
it('works only from allowed addresses and records where it was used', async () => {
const elsewhere = (
await createKey({
name: `Elsewhere ${suffix}`,
scopes: ['shop-sync:read'],
allowedIps: ['203.0.113.7'],
}).expect(201)
).body as CreatedKeyBody;
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${elsewhere.token}`)
.expect(403);
// Allow loopback (where this test runs) afterwards, without a new key.
await request(server())
.patch(`/api/v1/api-keys/${elsewhere.apiKey.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ allowedIps: ['127.0.0.0/8', '::1'] })
.expect(200);
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${elsewhere.token}`)
.expect(200);
const stored = await prisma.apiKey.findUniqueOrThrow({
where: { id: elsewhere.apiKey.id },
});
expect(stored.lastUsedIp).toMatch(/127\.0\.0\.1|::1/);
});
it('refuses an allowlist entry that is not an address or network', async () => {
await createKey({
name: `Bad allowlist ${suffix}`,
scopes: ['shop-sync:read'],
allowedIps: ['example.com'],
}).expect(400);
});
}); });
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parseAllowlist } from "./api-key-allowlist";
describe("parseAllowlist", () => {
it("accepts commas, spaces and new lines and drops blanks", () => {
expect(parseAllowlist(" 203.0.113.7,198.51.100.0/24\n\n2001:db8::1 ; ")).toEqual([
"203.0.113.7",
"198.51.100.0/24",
"2001:db8::1",
]);
});
it("returns an empty list for empty input", () => {
expect(parseAllowlist(" ")).toEqual([]);
});
});
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Globe, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
import { updateApiKeyAllowedIps } from "@/lib/api-keys/api";
import { ApiKey } from "@/lib/api-keys/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
/** "1.2.3.4, 5.6.7.0/24" or one per line -> list without blanks. */
export function parseAllowlist(text: string): string[] {
return text
.split(/[\s,;]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function messageOf(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
/**
* Where a key may be used from. Empty means "anywhere" and is shown as a
* warning - a stolen key would then work from any computer.
*/
export function ApiKeyAllowlist({ apiKey }: { apiKey: ApiKey }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [text, setText] = useState(apiKey.allowedIps.join("\n"));
const mutation = useMutation({
mutationFn: (allowedIps: string[]) => updateApiKeyAllowedIps(apiKey.id, allowedIps),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
toast.success("Erlaubte Adressen gespeichert.");
setEditing(false);
},
onError: (error) => toast.error(messageOf(error, "Adressen konnten nicht gespeichert werden.")),
});
const inputId = `allowlist-${apiKey.id}`;
const lastIpMissing =
apiKey.lastUsedIp !== null && !parseAllowlist(text).includes(apiKey.lastUsedIp);
if (!editing) {
return (
<div className="flex flex-wrap items-center gap-2 text-xs">
{apiKey.allowedIps.length > 0 ? (
<span className="flex items-center gap-1 text-muted-foreground">
<Globe className="size-3.5" aria-hidden />
Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span>
</span>
) : (
<span className="flex items-center gap-1 text-warning-foreground">
<ShieldAlert className="size-3.5 text-warning" aria-hidden />
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
</span>
)}
<Button type="button" size="sm" variant="ghost" onClick={() => setEditing(true)}>
Adressen ändern
</Button>
</div>
);
}
return (
<div className="flex w-full flex-col gap-2 rounded-lg border border-border/60 p-3">
<Label htmlFor={inputId}>Erlaubte IP-Adressen oder Netze</Label>
<textarea
id={inputId}
rows={3}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={"203.0.113.7\n198.51.100.0/24"}
className="rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
aria-describedby={`${inputId}-hint`}
/>
<p id={`${inputId}-hint`} className="text-xs text-muted-foreground">
Eine pro Zeile. Leer lassen = von überall nutzbar.
{apiKey.lastUsedIp ? ` Zuletzt benutzt von ${apiKey.lastUsedIp}.` : ""}
</p>
<div className="flex flex-wrap gap-2">
{lastIpMissing ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setText((current) => `${current.trim()}\n${apiKey.lastUsedIp}`.trim())}
>
{apiKey.lastUsedIp} übernehmen
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate(parseAllowlist(text))}
>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setText(apiKey.allowedIps.join("\n"));
setEditing(false);
}}
>
Abbrechen
</Button>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"]; const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
@@ -36,6 +37,7 @@ export function ApiKeysTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = useState("Wabenhain-Shop"); const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState(""); const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null); const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -51,7 +53,12 @@ export function ApiKeysTab() {
setCopied(false); setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-keys"] }); void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
}, },
onError: () => toast.error("Schlüssel konnte nicht angelegt werden."), onError: (error) =>
toast.error(
error instanceof Error && error.message
? error.message
: "Schlüssel konnte nicht angelegt werden.",
),
}); });
const revokeMutation = useMutation({ const revokeMutation = useMutation({
@@ -66,7 +73,12 @@ export function ApiKeysTab() {
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const days = expiresInDays ? Number(expiresInDays) : undefined; const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({ name: name.trim(), scopes: DEFAULT_SCOPES, expiresInDays: days }); createMutation.mutate({
name: name.trim(),
scopes: DEFAULT_SCOPES,
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
} }
async function copyToken() { async function copyToken() {
@@ -141,13 +153,26 @@ export function ApiKeysTab() {
onChange={(event) => setExpiresInDays(event.target.value)} onChange={(event) => setExpiresInDays(event.target.value)}
/> />
</div> </div>
<div className="flex min-w-56 flex-1 flex-col gap-1.5">
<Label htmlFor="api-key-ips">Nur von diesen IP-Adressen (empfohlen)</Label>
<Input
id="api-key-ips"
className="h-11 font-mono"
placeholder="z. B. 203.0.113.7"
value={allowedIps}
onChange={(event) => setAllowedIps(event.target.value)}
aria-describedby="api-key-ips-hint"
/>
</div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}> <Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
<KeyRound aria-hidden /> <KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"} {createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button> </Button>
</form> </form>
<p className="-mt-3 text-xs text-muted-foreground"> <p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]} Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
die zuletzt benutzte Adresse lässt sich übernehmen.
</p> </p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -179,7 +204,9 @@ export function ApiKeysTab() {
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "} Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)} {formatDate(apiKey.lastUsedAt)}
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""} {apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
{apiKey.lastUsedIp ? ` · von ${apiKey.lastUsedIp}` : ""}
</span> </span>
{state.active ? <ApiKeyAllowlist apiKey={apiKey} /> : null}
</div> </div>
{state.active ? ( {state.active ? (
<ConfirmDialog <ConfirmDialog
+7
View File
@@ -12,6 +12,13 @@ export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
}); });
} }
export function updateApiKeyAllowedIps(id: string, allowedIps: string[]): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}`, {
method: "PATCH",
body: JSON.stringify({ allowedIps }),
});
}
export function revokeApiKey(id: string): Promise<ApiKey> { export function revokeApiKey(id: string): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" }); return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
} }
@@ -12,6 +12,10 @@ export interface ApiKey {
createdByUserId: string | null; createdByUserId: string | null;
createdAt: string; createdAt: string;
lastUsedAt: string | null; lastUsedAt: string | null;
/** Source address of the last use - handy for filling in the allowlist. */
lastUsedIp: string | null;
/** IP addresses / CIDR networks the key works from; empty = anywhere. */
allowedIps: string[];
expiresAt: string | null; expiresAt: string | null;
revokedAt: string | null; revokedAt: string | null;
} }
@@ -20,6 +24,7 @@ export interface CreateApiKeyInput {
name: string; name: string;
scopes: ApiKeyScope[]; scopes: ApiKeyScope[];
expiresInDays?: number; expiresInDays?: number;
allowedIps?: string[];
} }
export interface CreatedApiKey { export interface CreatedApiKey {