diff --git a/app/(app)/users/page.tsx b/app/(app)/users/page.tsx index 6c4d517..62e9919 100644 --- a/app/(app)/users/page.tsx +++ b/app/(app)/users/page.tsx @@ -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(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() { Neuer BenutzerAccount anlegen.
setEmail(e.target.value)} />
-
setPassword(e.target.value)} />
+ setPwdValid(!!v.ok)} /> +
+ + setPassword2(e.target.value)} /> + {password2 && password !== password2 &&

Passwörter stimmen nicht überein.

} +
setNewPwd(e.target.value)} />
+
+ setNewPwdValid(!!v.ok)} label="Neues Passwort" id="newpwd" /> +
+ + setNewPwd2(e.target.value)} /> + {newPwd2 && newPwd !== newPwd2 &&

Passwörter stimmen nicht überein.

} +
+
- - + + diff --git a/app/api/caddy/reload/route.ts b/app/api/caddy/reload/route.ts index cf606e0..39d7dd4 100644 --- a/app/api/caddy/reload/route.ts +++ b/app/api/caddy/reload/route.ts @@ -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); } diff --git a/app/api/domains/[id]/verify/route.ts b/app/api/domains/[id]/verify/route.ts index 9779abc..1acd86e 100644 --- a/app/api/domains/[id]/verify/route.ts +++ b/app/api/domains/[id]/verify/route.ts @@ -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(); diff --git a/app/api/domains/bulk-delete/route.ts b/app/api/domains/bulk-delete/route.ts index 92e85ae..71cd1a7 100644 --- a/app/api/domains/bulk-delete/route.ts +++ b/app/api/domains/bulk-delete/route.ts @@ -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); diff --git a/app/api/domains/import.csv/route.ts b/app/api/domains/import.csv/route.ts index 0b097ed..0fbceb9 100644 --- a/app/api/domains/import.csv/route.ts +++ b/app/api/domains/import.csv/route.ts @@ -39,6 +39,7 @@ function parseCsv(text: string): Record[] { 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); diff --git a/app/api/domains/sunset-bulk/route.ts b/app/api/domains/sunset-bulk/route.ts index d90b139..89e611e 100644 --- a/app/api/domains/sunset-bulk/route.ts +++ b/app/api/domains/sunset-bulk/route.ts @@ -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); diff --git a/app/api/groups/[id]/route.ts b/app/api/groups/[id]/route.ts index e6f3578..8311f8e 100644 --- a/app/api/groups/[id]/route.ts +++ b/app/api/groups/[id]/route.ts @@ -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(); diff --git a/app/api/groups/route.ts b/app/api/groups/route.ts index 8dd4f1e..35ca5c2 100644 --- a/app/api/groups/route.ts +++ b/app/api/groups/route.ts @@ -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 }); diff --git a/app/api/password-check/route.ts b/app/api/password-check/route.ts new file mode 100644 index 0000000..43c2917 --- /dev/null +++ b/app/api/password-check/route.ts @@ -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); +} diff --git a/app/api/settings/geo/route.ts b/app/api/settings/geo/route.ts index 11d267c..f979fd4 100644 --- a/app/api/settings/geo/route.ts +++ b/app/api/settings/geo/route.ts @@ -41,6 +41,7 @@ async function tryDownload(url: string, headers: Record = {}): 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 }); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index e1d6c7b..d0ce0c0 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -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; diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts index 7601d1a..fe6e554 100644 --- a/app/api/setup/route.ts +++ b/app/api/setup/route.ts @@ -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(); diff --git a/app/api/tokens/[id]/route.ts b/app/api/tokens/[id]/route.ts index 183bd05..ed4fb4d 100644 --- a/app/api/tokens/[id]/route.ts +++ b/app/api/tokens/[id]/route.ts @@ -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 }); diff --git a/app/api/tokens/route.ts b/app/api/tokens/route.ts index 51248c2..ed6d173 100644 --- a/app/api/tokens/route.ts +++ b/app/api/tokens/route.ts @@ -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); diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 3a57b4a..9d1b8f7 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -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); diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 4590475..747bf4f 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -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; diff --git a/components/PasswordField.tsx b/components/PasswordField.tsx new file mode 100644 index 0000000..0c70c63 --- /dev/null +++ b/components/PasswordField.tsx @@ -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({ 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 ( +
+ +
+ onChange(e.target.value)} + disabled={disabled} + autoComplete={autoComplete} + className="pr-10" + /> + +
+ {value && ( +
+ {strength.checking ? ( + <>prüfe… + ) : strength.ok ? ( + <>Passwort OK + ) : ( + <>{strength.reason || "zu schwach"} + )} +
+ )} +
+ ); +} diff --git a/lib/api-auth.ts b/lib/api-auth.ts index fb26095..2928d38 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -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 }); } diff --git a/lib/passwords.ts b/lib/passwords.ts new file mode 100644 index 0000000..ada7ef0 --- /dev/null +++ b/lib/passwords.ts @@ -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 { + 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 { + 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; + } +}