v0.1.25 — security: passwort-bestätigung, HIBP-leak-check, role-enforcement auf alle mutations, API-rate-limits

This commit is contained in:
Hendrik 2026-05-01 21:59:52 +02:00
parent a4efe3bee2
commit 4803fe5afa
19 changed files with 206 additions and 16 deletions

View file

@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { PasswordField } from "@/components/PasswordField";
type U = { id: number; email: string; role: "admin" | "user"; created_at: number };
@ -18,11 +19,15 @@ export default function UsersPage() {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [pwdValid, setPwdValid] = useState(false);
const [role, setRole] = useState<"admin" | "user">("user");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [pwdOpen, setPwdOpen] = useState<U | null>(null);
const [newPwd, setNewPwd] = useState("");
const [newPwd2, setNewPwd2] = useState("");
const [newPwdValid, setNewPwdValid] = useState(false);
async function load() {
setLoading(true);
@ -36,13 +41,15 @@ export default function UsersPage() {
async function create(e: React.FormEvent) {
e.preventDefault();
if (password !== password2) { setErr("Passwörter stimmen nicht überein."); return; }
if (!pwdValid) { setErr("Passwort erfüllt die Anforderungen nicht."); return; }
setBusy(true);
setErr("");
try {
const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, role }) });
const d = await r.json();
if (!r.ok) { setErr(d.error || "Fehler"); return; }
setEmail(""); setPassword(""); setRole("user");
if (!r.ok) { setErr(d.reason || d.error || "Fehler"); return; }
setEmail(""); setPassword(""); setPassword2(""); setRole("user");
setOpen(false);
load();
} finally { setBusy(false); }
@ -57,11 +64,12 @@ export default function UsersPage() {
load();
}
async function setPassword2(u: U, pwd: string) {
const r = await fetch(`/api/users/${u.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pwd }) });
if (!r.ok) { alert("Fehler"); return; }
setPwdOpen(null);
setNewPwd("");
async function changePassword(u: U) {
if (newPwd !== newPwd2) { alert("Passwörter stimmen nicht überein."); return; }
if (!newPwdValid) { alert("Passwort erfüllt die Anforderungen nicht."); return; }
const r = await fetch(`/api/users/${u.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: newPwd }) });
if (!r.ok) { const d = await r.json().catch(() => ({})); alert(d.reason || d.error || "Fehler"); return; }
setPwdOpen(null); setNewPwd(""); setNewPwd2("");
}
async function del(u: U) {
@ -96,7 +104,12 @@ export default function UsersPage() {
<DialogHeader><DialogTitle>Neuer Benutzer</DialogTitle><DialogDescription>Account anlegen.</DialogDescription></DialogHeader>
<form onSubmit={create} className="space-y-3">
<div className="space-y-1"><Label>E-Mail</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="space-y-1"><Label>Passwort (min 8)</Label><Input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} /></div>
<PasswordField value={password} onChange={setPassword} onValidationChange={(v) => setPwdValid(!!v.ok)} />
<div className="space-y-1">
<Label htmlFor="pw2">Passwort wiederholen</Label>
<Input id="pw2" type="password" required value={password2} onChange={(e) => setPassword2(e.target.value)} />
{password2 && password !== password2 && <p className="text-[11px] text-amber-400">Passwörter stimmen nicht überein.</p>}
</div>
<div className="space-y-1">
<Label>Rolle</Label>
<select value={role} onChange={(e) => setRole(e.target.value as "admin" | "user")} className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 text-sm text-zinc-100">
@ -162,13 +175,20 @@ export default function UsersPage() {
)}
</div>
<Dialog open={!!pwdOpen} onOpenChange={(v) => { if (!v) { setPwdOpen(null); setNewPwd(""); } }}>
<Dialog open={!!pwdOpen} onOpenChange={(v) => { if (!v) { setPwdOpen(null); setNewPwd(""); setNewPwd2(""); } }}>
<DialogContent>
<DialogHeader><DialogTitle>Passwort ändern</DialogTitle><DialogDescription>{pwdOpen?.email}</DialogDescription></DialogHeader>
<div className="space-y-1"><Label>Neues Passwort (min 8)</Label><Input type="password" minLength={8} value={newPwd} onChange={(e) => setNewPwd(e.target.value)} /></div>
<div className="space-y-3">
<PasswordField value={newPwd} onChange={setNewPwd} onValidationChange={(v) => setNewPwdValid(!!v.ok)} label="Neues Passwort" id="newpwd" />
<div className="space-y-1">
<Label htmlFor="newpwd2">Wiederholen</Label>
<Input id="newpwd2" type="password" value={newPwd2} onChange={(e) => setNewPwd2(e.target.value)} />
{newPwd2 && newPwd !== newPwd2 && <p className="text-[11px] text-amber-400">Passwörter stimmen nicht überein.</p>}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setPwdOpen(null); setNewPwd(""); }}>Abbrechen</Button>
<Button onClick={() => pwdOpen && setPassword2(pwdOpen, newPwd)} disabled={newPwd.length < 8}>Speichern</Button>
<Button variant="outline" onClick={() => { setPwdOpen(null); setNewPwd(""); setNewPwd2(""); }}>Abbrechen</Button>
<Button onClick={() => pwdOpen && changePassword(pwdOpen)} disabled={!newPwdValid || newPwd !== newPwd2}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -6,6 +6,7 @@ import { reloadCaddy, buildCaddyfile } from "@/lib/caddy";
export async function POST() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const result = await reloadCaddy();
return NextResponse.json(result);
}

View file

@ -10,6 +10,7 @@ import { fireWebhook } from "@/lib/webhook";
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
const db = getDb();

View file

@ -13,6 +13,7 @@ const schema = z.object({
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);

View file

@ -39,6 +39,7 @@ function parseCsv(text: string): Record<string, string>[] {
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const text = await req.text();
const rows = parseCsv(text);

View file

@ -21,6 +21,7 @@ const bodySchema = z.object({
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => null);
const parsed = bodySchema.safeParse(body);

View file

@ -15,6 +15,7 @@ const updateSchema = z.object({
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
const body = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(body);
@ -42,6 +43,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
const db = getDb();

View file

@ -23,6 +23,7 @@ const schema = z.object({
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });

View file

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { validatePassword } from "@/lib/passwords";
import { checkLimit } from "@/lib/rate-limit";
export async function POST(req: Request) {
// Public during setup, otherwise authed. Rate-limit either way.
const session = await getServerSession(authOptions);
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon";
const limit = checkLimit(`pwcheck:${session?.user?.id ?? ip}`, 30, 60_000);
if (!limit.allowed) return NextResponse.json({ error: "rate_limited" }, { status: 429 });
const body = await req.json().catch(() => null) as { password?: string } | null;
if (!body?.password) return NextResponse.json({ ok: false, reason: "Passwort leer." });
const result = await validatePassword(body.password);
return NextResponse.json(result);
}

View file

@ -41,6 +41,7 @@ async function tryDownload(url: string, headers: Record<string, string> = {}): P
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
@ -111,6 +112,7 @@ export async function POST(req: Request) {
export async function DELETE() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
await fs.unlink(MMDB_PATH).catch(() => {});
resetGeoReader();
return NextResponse.json({ ok: true });

View file

@ -16,6 +16,7 @@ export async function GET() {
export async function PATCH(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => ({}));
for (const [k, v] of Object.entries(body)) {
if (!PUBLIC_KEYS.includes(k)) continue;

View file

@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { getDb, isSetupComplete, setSetting, getDailySalt } from "@/lib/db";
import { validatePassword } from "@/lib/passwords";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
password: z.string().min(10),
baseDomain: z.string().optional(),
});
@ -21,6 +22,10 @@ export async function POST(req: Request) {
}
const { email, password, baseDomain } = parsed.data;
const pwdCheck = await validatePassword(password);
if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 });
const password_hash = await bcrypt.hash(password, 12);
const now = Date.now();

View file

@ -6,6 +6,7 @@ import { getDb } from "@/lib/db";
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
getDb().prepare("UPDATE api_tokens SET revoked_at = ? WHERE id = ?").run(Date.now(), Number(id));
return NextResponse.json({ ok: true });

View file

@ -22,6 +22,7 @@ const schema = z.object({
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);

View file

@ -3,10 +3,11 @@ import { z } from "zod";
import bcrypt from "bcryptjs";
import { getDb, logAudit, type UserRow } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
import { validatePassword } from "@/lib/passwords";
const updateSchema = z.object({
role: z.enum(["admin", "user"]).optional(),
password: z.string().min(8).optional(),
password: z.string().min(10).optional(),
});
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
@ -34,6 +35,8 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
values.push(parsed.data.role);
}
if (parsed.data.password) {
const pwdCheck = await validatePassword(parsed.data.password);
if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 });
const hash = await bcrypt.hash(parsed.data.password, 12);
fields.push("password_hash = ?");
values.push(hash);

View file

@ -3,6 +3,7 @@ import { z } from "zod";
import bcrypt from "bcryptjs";
import { getDb, logAudit, type UserRow } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
import { validatePassword } from "@/lib/passwords";
export async function GET() {
const u = await requireAdmin();
@ -13,7 +14,7 @@ export async function GET() {
const createSchema = z.object({
email: z.string().email().transform((s) => s.toLowerCase().trim()),
password: z.string().min(8),
password: z.string().min(10),
role: z.enum(["admin", "user"]).default("user"),
});
@ -29,6 +30,8 @@ export async function POST(req: Request) {
if (db.prepare("SELECT id FROM users WHERE email = ?").get(parsed.data.email)) {
return NextResponse.json({ error: "email_exists" }, { status: 409 });
}
const pwdCheck = await validatePassword(parsed.data.password);
if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 });
const hash = await bcrypt.hash(parsed.data.password, 12);
const result = db.prepare("INSERT INTO users (email, password_hash, role, created_at) VALUES (?, ?, ?, ?)").run(parsed.data.email, hash, parsed.data.role, Date.now());
const row = db.prepare("SELECT id, email, role, created_at FROM users WHERE id = ?").get(result.lastInsertRowid) as UserRow;

View file

@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { Eye, EyeOff, Check, AlertTriangle, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type Strength = { ok: boolean; reason?: string; checking?: boolean };
export function PasswordField({
value, onChange, onValidationChange, label = "Passwort", id = "password", autoComplete = "new-password", disabled,
}: {
value: string;
onChange: (v: string) => void;
onValidationChange?: (v: Strength) => void;
label?: string;
id?: string;
autoComplete?: string;
disabled?: boolean;
}) {
const [show, setShow] = useState(false);
const [strength, setStrength] = useState<Strength>({ ok: false });
useEffect(() => {
if (!value) { setStrength({ ok: false }); onValidationChange?.({ ok: false }); return; }
setStrength((s) => ({ ...s, checking: true }));
const t = setTimeout(async () => {
try {
const r = await fetch("/api/password-check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: value }) });
const d = await r.json();
const next: Strength = { ok: !!d.ok, reason: d.reason };
setStrength(next);
onValidationChange?.(next);
} catch {
setStrength({ ok: false, reason: "Prüfung nicht möglich." });
}
}, 400);
return () => clearTimeout(t);
}, [value, onValidationChange]);
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<div className="relative">
<Input
id={id}
type={show ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
autoComplete={autoComplete}
className="pr-10"
/>
<button type="button" onClick={() => setShow((s) => !s)} className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-200" tabIndex={-1}>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{value && (
<div className="flex items-start gap-2 text-[11px]">
{strength.checking ? (
<><Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /><span className="text-muted-foreground">prüfe</span></>
) : strength.ok ? (
<><Check className="h-3 w-3 text-green-400" /><span className="text-green-400">Passwort OK</span></>
) : (
<><AlertTriangle className="h-3 w-3 text-amber-400" /><span className="text-amber-400">{strength.reason || "zu schwach"}</span></>
)}
</div>
)}
</div>
);
}

View file

@ -1,6 +1,7 @@
import crypto from "crypto";
import { NextResponse } from "next/server";
import { getDb, type ApiTokenRow } from "./db";
import { checkLimit } from "./rate-limit";
export type Scope = "read:domains" | "write:domains" | "read:analytics" | "read:hits";
export const ALL_SCOPES: Scope[] = ["read:domains", "write:domains", "read:analytics", "read:hits"];
@ -43,7 +44,16 @@ export function authenticateToken(req: Request): AuthedToken | null {
export function requireScope(req: Request, scope: Scope): AuthedToken | NextResponse {
const t = authenticateToken(req);
if (!t) return NextResponse.json({ error: "unauthorized", code: "no_token" }, { status: 401 });
if (!t) {
// Rate-limit by IP for unauth: 30 attempts / minute
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon";
const rl = checkLimit(`api:unauth:${ip}`, 30, 60_000);
if (!rl.allowed) return NextResponse.json({ error: "rate_limited", retry_after: rl.retryAfterSec }, { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } });
return NextResponse.json({ error: "unauthorized", code: "no_token" }, { status: 401 });
}
// Per-token rate-limit: 60 / minute
const rl = checkLimit(`api:token:${t.id}`, 60, 60_000);
if (!rl.allowed) return NextResponse.json({ error: "rate_limited", retry_after: rl.retryAfterSec }, { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } });
if (!t.scopes.includes(scope)) {
return NextResponse.json({ error: "forbidden", code: "missing_scope", required: scope }, { status: 403 });
}

47
lib/passwords.ts Normal file
View file

@ -0,0 +1,47 @@
import crypto from "crypto";
export type PasswordCheck = { ok: true } | { ok: false; reason: string };
const COMMON = new Set([
"password", "passwort", "12345678", "123456789", "1234567890",
"qwertz123", "qwerty123", "admin1234", "password1", "welcome123",
"letmein123", "iloveyou1", "test1234", "changeme123",
]);
export async function validatePassword(pwd: string, opts: { checkPwned?: boolean } = {}): Promise<PasswordCheck> {
if (typeof pwd !== "string") return { ok: false, reason: "Ungültig." };
if (pwd.length < 10) return { ok: false, reason: "Mindestens 10 Zeichen." };
if (pwd.length > 200) return { ok: false, reason: "Maximal 200 Zeichen." };
if (!/[a-zA-Z]/.test(pwd)) return { ok: false, reason: "Mindestens 1 Buchstabe." };
if (!/[0-9]/.test(pwd)) return { ok: false, reason: "Mindestens 1 Ziffer." };
if (COMMON.has(pwd.toLowerCase())) return { ok: false, reason: "Zu trivial — eines der bekanntesten Passwörter." };
if (opts.checkPwned !== false) {
const pwned = await pwnedCount(pwd);
if (pwned > 0) {
return { ok: false, reason: `Passwort wurde in ${pwned.toLocaleString("de-DE")} bekannten Datenpannen gefunden — bitte ein anderes wählen.` };
}
}
return { ok: true };
}
async function pwnedCount(pwd: string): Promise<number> {
try {
const sha = crypto.createHash("sha1").update(pwd).digest("hex").toUpperCase();
const prefix = sha.slice(0, 5);
const suffix = sha.slice(5);
const r = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { "Add-Padding": "true", "User-Agent": "nexredirect-password-check" },
signal: AbortSignal.timeout(5000),
});
if (!r.ok) return 0;
const text = await r.text();
for (const line of text.split("\n")) {
const [hashSuffix, count] = line.trim().split(":");
if (hashSuffix === suffix) return Number(count) || 0;
}
return 0;
} catch {
return 0;
}
}