v0.1.25 — security: passwort-bestätigung, HIBP-leak-check, role-enforcement auf alle mutations, API-rate-limits
This commit is contained in:
parent
a4efe3bee2
commit
4803fe5afa
19 changed files with 206 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
19
app/api/password-check/route.ts
Normal file
19
app/api/password-check/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
components/PasswordField.tsx
Normal file
70
components/PasswordField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
47
lib/passwords.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue