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,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
|
||||
|
||||
Reference in new issue
Block a user