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:
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;
|
||||
@@ -147,6 +147,10 @@ model ApiKey {
|
||||
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
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?
|
||||
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 { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||
import {
|
||||
ApiKeySummary,
|
||||
ApiKeysService,
|
||||
@@ -31,6 +32,16 @@ export class ApiKeysController {
|
||||
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')
|
||||
@Post(':id/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 { 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';
|
||||
|
||||
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}$/;
|
||||
@@ -48,9 +66,12 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ApiKeySummary[]> {
|
||||
@@ -64,6 +85,15 @@ export class ApiKeysService {
|
||||
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.
|
||||
@@ -79,6 +109,7 @@ export class ApiKeysService {
|
||||
prefix,
|
||||
keyHash: hashToken(token),
|
||||
scopes: [...new Set(dto.scopes)],
|
||||
allowedIps: normalizeAllowlist(dto.allowedIps),
|
||||
expiresAt,
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
@@ -94,6 +125,31 @@ export class ApiKeysService {
|
||||
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) {
|
||||
@@ -121,7 +177,10 @@ 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.
|
||||
*/
|
||||
async authenticate(token: string): Promise<ApiKeyPrincipal | null> {
|
||||
async authenticate(
|
||||
token: string,
|
||||
clientIp?: string,
|
||||
): Promise<ApiKeyPrincipal | null> {
|
||||
if (!TOKEN_PATTERN.test(token)) {
|
||||
return null;
|
||||
}
|
||||
@@ -136,13 +195,24 @@ export class ApiKeysService {
|
||||
) {
|
||||
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 (
|
||||
!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({
|
||||
where: { id: apiKey.id },
|
||||
data: { lastUsedAt: now },
|
||||
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
||||
});
|
||||
}
|
||||
// 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 {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
@@ -11,8 +12,11 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
import { API_KEY_SCOPES } from '../api-key-scopes';
|
||||
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({
|
||||
@@ -39,4 +43,16 @@ export class CreateApiKeyDto {
|
||||
@Min(1)
|
||||
@Max(730)
|
||||
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 {
|
||||
headers: { authorization?: string };
|
||||
ip?: string;
|
||||
user?: AuthenticatedUser;
|
||||
apiKey?: ApiKeyPrincipal;
|
||||
}
|
||||
@@ -55,7 +56,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
// PermissionsGuard checks its scopes instead of a role.
|
||||
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
||||
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) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
@@ -190,4 +190,42 @@ describe('API keys (e2e)', () => {
|
||||
.send({ name: 'x', scopes: ['shop-sync:read'] })
|
||||
.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 { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
|
||||
|
||||
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
|
||||
|
||||
@@ -36,6 +37,7 @@ export function ApiKeysTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("Wabenhain-Shop");
|
||||
const [expiresInDays, setExpiresInDays] = useState("");
|
||||
const [allowedIps, setAllowedIps] = useState("");
|
||||
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -51,7 +53,12 @@ export function ApiKeysTab() {
|
||||
setCopied(false);
|
||||
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({
|
||||
@@ -66,7 +73,12 @@ export function ApiKeysTab() {
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
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() {
|
||||
@@ -141,13 +153,26 @@ export function ApiKeysTab() {
|
||||
onChange={(event) => setExpiresInDays(event.target.value)}
|
||||
/>
|
||||
</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()}>
|
||||
<KeyRound aria-hidden />
|
||||
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="-mt-3 text-xs text-muted-foreground">
|
||||
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}
|
||||
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
|
||||
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>
|
||||
|
||||
{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{" "}
|
||||
{formatDate(apiKey.lastUsedAt)}
|
||||
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
|
||||
{apiKey.lastUsedIp ? ` · von ${apiKey.lastUsedIp}` : ""}
|
||||
</span>
|
||||
{state.active ? <ApiKeyAllowlist apiKey={apiKey} /> : null}
|
||||
</div>
|
||||
{state.active ? (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -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> {
|
||||
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
@@ -12,6 +12,10 @@ export interface ApiKey {
|
||||
createdByUserId: string | null;
|
||||
createdAt: string;
|
||||
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;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
@@ -20,6 +24,7 @@ export interface CreateApiKeyInput {
|
||||
name: string;
|
||||
scopes: ApiKeyScope[];
|
||||
expiresInDays?: number;
|
||||
allowedIps?: string[];
|
||||
}
|
||||
|
||||
export interface CreatedApiKey {
|
||||
|
||||
Reference in new issue
Block a user