v0.1.26 — SMTP + Passwort-vergessen, Username-Login, SHA256-Verifikation der Update-Tarballs

This commit is contained in:
Hendrik 2026-05-01 22:06:55 +02:00
parent 19d16bd0c5
commit c60a38091b
15 changed files with 579 additions and 35 deletions

View file

@ -31,9 +31,17 @@ jobs:
tar -czf "nexredirect-next-${TAG}.tar.gz" .next
ls -lh "nexredirect-next-${TAG}.tar.gz"
- name: Compute SHA256
run: |
TAG="${GITHUB_REF_NAME}"
sha256sum "nexredirect-next-${TAG}.tar.gz" > "nexredirect-checksums-${TAG}.txt"
cat "nexredirect-checksums-${TAG}.txt"
- name: Attach to release
uses: softprops/action-gh-release@v2
with:
files: nexredirect-next-*.tar.gz
files: |
nexredirect-next-*.tar.gz
nexredirect-checksums-*.txt
tag_name: ${{ github.ref_name }}
fail_on_unmatched_files: true

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2 } from "lucide-react";
import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -34,20 +34,51 @@ export default function SettingsPage() {
const [accountId, setAccountId] = useState("");
const [installingGeo, setInstallingGeo] = useState(false);
const [geoMsg, setGeoMsg] = useState("");
const [smtp, setSmtp] = useState<{ smtp_host: string; smtp_port: string; smtp_user: string; smtp_password: string; smtp_from: string; smtp_secure: string }>({ smtp_host: "", smtp_port: "587", smtp_user: "", smtp_password: "", smtp_from: "", smtp_secure: "false" });
const [smtpSaving, setSmtpSaving] = useState(false);
const [smtpTestTo, setSmtpTestTo] = useState("");
const [smtpTestBusy, setSmtpTestBusy] = useState(false);
const [smtpTestMsg, setSmtpTestMsg] = useState("");
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [msg, setMsg] = useState("");
async function load() {
const [s, u, g] = await Promise.all([
const [s, u, g, m] = await Promise.all([
fetch("/api/settings").then((r) => r.json()),
fetch("/api/update/check").then((r) => r.json()),
fetch("/api/settings/geo").then((r) => r.json()),
fetch("/api/settings/smtp").then((r) => r.json()),
]);
setSettings(s);
setStatus(u);
setGeo(g);
setSmtp({
smtp_host: m.smtp_host || "",
smtp_port: m.smtp_port || "587",
smtp_user: m.smtp_user || "",
smtp_password: m.smtp_password || "",
smtp_from: m.smtp_from || "",
smtp_secure: m.smtp_secure || "false",
});
}
async function saveSmtp() {
setSmtpSaving(true);
try {
await fetch("/api/settings/smtp", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(smtp) });
} finally { setSmtpSaving(false); }
}
async function sendTestMail() {
setSmtpTestBusy(true);
setSmtpTestMsg("");
try {
const r = await fetch("/api/settings/smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: smtpTestTo || undefined }) });
const d = await r.json();
setSmtpTestMsg(d.ok ? "Test-Mail verschickt." : `Fehler: ${d.error}`);
} finally { setSmtpTestBusy(false); }
}
useEffect(() => { load(); }, []);
@ -332,6 +363,62 @@ export default function SettingsPage() {
{geoMsg && <p className="text-xs text-muted-foreground">{geoMsg}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />SMTP / E-Mail
{smtp.smtp_host
? <Badge variant="green"><CheckCircle2 className="mr-1 h-3 w-3" />konfiguriert</Badge>
: <Badge variant="zinc">nicht konfiguriert</Badge>}
</CardTitle>
<CardDescription>Für Passwort-Reset-Mails. Postfix/Sendgrid/Mailgun/Hetzner-SMTP alles was speaks SMTP.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">SMTP-Host</Label>
<Input value={smtp.smtp_host} onChange={(e) => setSmtp({ ...smtp, smtp_host: e.target.value })} placeholder="smtp.example.com" />
</div>
<div className="space-y-1">
<Label className="text-xs">Port</Label>
<Input type="number" value={smtp.smtp_port} onChange={(e) => setSmtp({ ...smtp, smtp_port: e.target.value })} placeholder="587" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Username</Label>
<Input value={smtp.smtp_user} onChange={(e) => setSmtp({ ...smtp, smtp_user: e.target.value })} autoComplete="off" />
</div>
<div className="space-y-1">
<Label className="text-xs">Passwort</Label>
<Input type="password" value={smtp.smtp_password} onChange={(e) => setSmtp({ ...smtp, smtp_password: e.target.value })} autoComplete="new-password" placeholder={smtp.smtp_password === "***" ? "(unverändert)" : ""} />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">From-Adresse</Label>
<Input value={smtp.smtp_from} onChange={(e) => setSmtp({ ...smtp, smtp_from: e.target.value })} placeholder='"NexRedirect" <noreply@example.com>' />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={smtp.smtp_secure === "true"} onChange={(e) => setSmtp({ ...smtp, smtp_secure: e.target.checked ? "true" : "false" })} />
TLS direkt (Port 465). Sonst STARTTLS auf 587.
</label>
<div className="flex gap-2">
<Button onClick={saveSmtp} size="sm" disabled={smtpSaving}>
{smtpSaving ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}
Speichern
</Button>
<div className="flex flex-1 gap-2">
<Input placeholder="Test-Empfänger (leer = eigene)" value={smtpTestTo} onChange={(e) => setSmtpTestTo(e.target.value)} />
<Button onClick={sendTestMail} variant="outline" size="sm" disabled={smtpTestBusy || !smtp.smtp_host}>
{smtpTestBusy ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Send className="mr-2 h-3 w-3" />}
Test
</Button>
</div>
</div>
{smtpTestMsg && <p className="text-xs text-muted-foreground">{smtpTestMsg}</p>}
</CardContent>
</Card>
</div>
</div>
);

View file

@ -10,7 +10,7 @@ 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 };
type U = { id: number; email: string; username: string | null; role: "admin" | "user"; created_at: number };
export default function UsersPage() {
const [users, setUsers] = useState<U[]>([]);
@ -18,6 +18,7 @@ export default function UsersPage() {
const [forbidden, setForbidden] = useState(false);
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [pwdValid, setPwdValid] = useState(false);
@ -46,10 +47,10 @@ export default function UsersPage() {
setBusy(true);
setErr("");
try {
const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, role }) });
const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, username: username.trim() || undefined, password, role }) });
const d = await r.json();
if (!r.ok) { setErr(d.reason || d.error || "Fehler"); return; }
setEmail(""); setPassword(""); setPassword2(""); setRole("user");
setEmail(""); setUsername(""); setPassword(""); setPassword2(""); setRole("user");
setOpen(false);
load();
} finally { setBusy(false); }
@ -104,6 +105,11 @@ 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>Benutzername <span className="text-muted-foreground">(optional)</span></Label>
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="z.B. hendrik" pattern="[a-zA-Z0-9_-]+" minLength={3} maxLength={40} />
<p className="text-[11px] text-muted-foreground">Login mit Username möglich. Buchstaben/Ziffern/-_, 340 Zeichen.</p>
</div>
<PasswordField value={password} onChange={setPassword} onValidationChange={(v) => setPwdValid(!!v.ok)} />
<div className="space-y-1">
<Label htmlFor="pw2">Passwort wiederholen</Label>
@ -138,6 +144,7 @@ export default function UsersPage() {
<thead className="border-b border-zinc-800/70 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-6 py-3 text-left">E-Mail</th>
<th className="px-6 py-3 text-left">Benutzername</th>
<th className="px-6 py-3 text-left">Rolle</th>
<th className="px-6 py-3 text-left">Erstellt</th>
<th className="px-6 py-3 text-right"></th>
@ -147,6 +154,7 @@ export default function UsersPage() {
{users.map((u) => (
<tr key={u.id}>
<td className="px-6 py-3">{u.email}</td>
<td className="px-6 py-3 font-mono text-xs">{u.username || "—"}</td>
<td className="px-6 py-3">
{u.role === "admin"
? <Badge variant="green"><Shield className="mr-1 h-3 w-3" />admin</Badge>

View file

@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo";
export default function ForgotPage() {
const [identifier, setIdentifier] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const r = await fetch("/api/auth/forgot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: identifier.trim() }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
if (d.error === "smtp_not_configured") setError("Server hat keinen Mail-Versand konfiguriert. Bitte Admin kontaktieren.");
else if (d.error === "rate_limited") setError("Zu viele Versuche. Bitte später erneut.");
else setError("Fehler. Versuche es später nochmal.");
return;
}
setDone(true);
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4"><Logo size={48} /></div>
<CardTitle className="text-2xl">Passwort vergessen</CardTitle>
<CardDescription>Wir senden dir einen Link zum Zurücksetzen.</CardDescription>
</CardHeader>
<CardContent>
{done ? (
<div className="space-y-4">
<p className="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-300">
Falls ein Account zu deinen Angaben existiert, wurde eine Mail verschickt. Prüfe auch deinen Spam-Ordner.
</p>
<Button asChild variant="outline" className="w-full">
<Link href="/login"><ArrowLeft className="mr-2 h-4 w-4" />Zurück zum Login</Link>
</Button>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ident">E-Mail oder Benutzername</Label>
<Input id="ident" required value={identifier} onChange={(e) => setIdentifier(e.target.value)} disabled={loading} autoComplete="username" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !identifier.trim()}>
{loading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sende...</> : "Reset-Link senden"}
</Button>
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/login">Zurück zum Login</Link>
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -11,20 +12,20 @@ import { Logo } from "@/components/Logo";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !password) return;
if (!identifier.trim() || !password) return;
setLoading(true);
setError("");
try {
const result = await signIn("credentials", { email: email.trim(), password, redirect: false });
const result = await signIn("credentials", { email: identifier.trim(), password, redirect: false });
if (result?.error) {
setError("Ungültige E-Mail oder falsches Passwort.");
setError("Ungültige Anmeldedaten.");
} else {
router.push("/dashboard");
router.refresh();
@ -44,20 +45,23 @@ export default function LoginPage() {
<Logo size={48} />
</div>
<CardTitle className="text-2xl">NexRedirect</CardTitle>
<CardDescription>Melde dich mit deinem Admin-Account an</CardDescription>
<CardDescription>Melde dich mit E-Mail oder Benutzername an</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} disabled={loading} autoComplete="email" />
<Label htmlFor="identifier">E-Mail oder Benutzername</Label>
<Input id="identifier" type="text" required value={identifier} onChange={(e) => setIdentifier(e.target.value)} disabled={loading} autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<div className="flex items-center justify-between">
<Label htmlFor="password">Passwort</Label>
<Link href="/forgot" className="text-[11px] text-cyan-400 hover:underline">Vergessen?</Link>
</div>
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} disabled={loading} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !email.trim() || !password}>
<Button type="submit" className="w-full" disabled={loading || !identifier.trim() || !password}>
{loading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Anmelden...</> : "Anmelden"}
</Button>
</form>

View file

@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo";
import { PasswordField } from "@/components/PasswordField";
export default function ResetPage() {
const params = useParams<{ token: string }>();
const router = useRouter();
const [validating, setValidating] = useState(true);
const [valid, setValid] = useState(false);
const [pwd, setPwd] = useState("");
const [pwd2, setPwd2] = useState("");
const [pwdValid, setPwdValid] = useState(false);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
fetch(`/api/auth/reset?token=${encodeURIComponent(params.token)}`)
.then((r) => r.json())
.then((d) => setValid(!!d.valid))
.finally(() => setValidating(false));
}, [params.token]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (pwd !== pwd2) { setError("Passwörter stimmen nicht überein."); return; }
if (!pwdValid) { setError("Passwort erfüllt die Anforderungen nicht."); return; }
setBusy(true);
setError("");
try {
const r = await fetch("/api/auth/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: params.token, password: pwd }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { setError(d.reason || d.error || "Fehler."); return; }
setDone(true);
setTimeout(() => router.push("/login"), 2000);
} finally { setBusy(false); }
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4"><Logo size={48} /></div>
<CardTitle className="text-2xl">Neues Passwort setzen</CardTitle>
<CardDescription>{validating ? "Token prüfen…" : valid ? "Wähle ein neues Passwort." : "Token ungültig oder abgelaufen."}</CardDescription>
</CardHeader>
<CardContent>
{validating ? (
<div className="flex justify-center py-6"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : !valid ? (
<div className="space-y-3">
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-300">
Der Reset-Link ist nicht (mehr) gültig. Fordere einen neuen an.
</p>
<Button asChild variant="outline" className="w-full"><Link href="/forgot">Neuen Link anfordern</Link></Button>
<Button asChild variant="ghost" size="sm" className="w-full"><Link href="/login"><ArrowLeft className="mr-2 h-3 w-3" />Login</Link></Button>
</div>
) : done ? (
<div className="space-y-3">
<p className="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-300">Passwort gesetzt leite zum Login weiter</p>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<PasswordField value={pwd} onChange={setPwd} onValidationChange={(v) => setPwdValid(!!v.ok)} disabled={busy} />
<div className="space-y-2">
<Label htmlFor="pw2">Wiederholen</Label>
<Input id="pw2" type="password" required value={pwd2} onChange={(e) => setPwd2(e.target.value)} disabled={busy} />
{pwd2 && pwd !== pwd2 && <p className="text-[11px] text-amber-400">Passwörter stimmen nicht überein.</p>}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy || !pwdValid || pwd !== pwd2}>
{busy ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Setze</> : "Passwort setzen"}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import crypto from "crypto";
import { getDb, getSetting, logAudit, type UserRow } from "@/lib/db";
import { sendMail, getSmtpConfig } from "@/lib/mailer";
import { checkLimit } from "@/lib/rate-limit";
const schema = z.object({
identifier: z.string().min(1).max(200),
});
export async function POST(req: Request) {
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon";
const rl = checkLimit(`forgot:ip:${ip}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return NextResponse.json({ error: "rate_limited" }, { status: 429 });
}
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
const id = parsed.data.identifier.toLowerCase().trim();
const db = getDb();
const user = db.prepare("SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1").get(id, id) as UserRow | undefined;
// Always return ok so attackers can't enumerate users
if (!user) {
await new Promise((r) => setTimeout(r, 300));
return NextResponse.json({ ok: true });
}
if (!getSmtpConfig()) {
return NextResponse.json({ error: "smtp_not_configured" }, { status: 503 });
}
// Generate token (raw → URL; hash → DB)
const raw = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(raw).digest("hex");
const expiresAt = Date.now() + 60 * 60 * 1000; // 60 min
db.prepare("INSERT INTO password_resets (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)")
.run(user.id, tokenHash, Date.now(), expiresAt);
const baseDomain = getSetting("base_domain");
const proto = baseDomain ? "https" : "http";
const host = baseDomain || (getSetting("server_ip") || "localhost");
const link = `${proto}://${host}/reset/${raw}`;
const subject = "NexRedirect — Passwort zurücksetzen";
const html = `
<p>Hallo,</p>
<p>Du (oder jemand mit Kenntnis deines Accounts) hat ein Passwort-Reset für <strong>${user.email}</strong> bei NexRedirect angefordert.</p>
<p><a href="${link}"> Neues Passwort setzen</a></p>
<p>Der Link ist <strong>60 Minuten</strong> gültig und kann nur einmal verwendet werden.</p>
<p style="color:#666;font-size:12px">Falls du das nicht warst, ignoriere diese Mail. Niemand erhält Zugriff ohne den Link.</p>
`;
const result = await sendMail({ to: user.email, subject, html });
if (!result.ok) {
// Don't leak details; admin sees in audit
logAudit({ user_id: user.id, user_email: user.email, action: "auth.forgot_send_failed", target_type: "user", target_id: user.id, details: { error: result.error } });
return NextResponse.json({ error: "send_failed" }, { status: 500 });
}
logAudit({ user_id: user.id, user_email: user.email, action: "auth.forgot", target_type: "user", target_id: user.id });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { getDb, logAudit, type UserRow } from "@/lib/db";
import { validatePassword } from "@/lib/passwords";
import { checkLimit } from "@/lib/rate-limit";
const schema = z.object({
token: z.string().min(20).max(200),
password: z.string().min(10),
});
export async function POST(req: Request) {
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "anon";
const rl = checkLimit(`reset:ip:${ip}`, 10, 60 * 60 * 1000);
if (!rl.allowed) return NextResponse.json({ error: "rate_limited" }, { status: 429 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
const tokenHash = crypto.createHash("sha256").update(parsed.data.token).digest("hex");
const db = getDb();
const reset = db.prepare("SELECT id, user_id, expires_at, used_at FROM password_resets WHERE token_hash = ? LIMIT 1").get(tokenHash) as
| { id: number; user_id: number; expires_at: number; used_at: number | null } | undefined;
if (!reset || reset.used_at !== null || reset.expires_at < Date.now()) {
return NextResponse.json({ error: "invalid_or_expired" }, { status: 400 });
}
const pwdCheck = await validatePassword(parsed.data.password);
if (!pwdCheck.ok) return NextResponse.json({ error: "weak_password", reason: pwdCheck.reason }, { status: 400 });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(reset.user_id) as UserRow | undefined;
if (!user) return NextResponse.json({ error: "user_gone" }, { status: 400 });
const hash = await bcrypt.hash(parsed.data.password, 12);
db.transaction(() => {
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id);
db.prepare("UPDATE password_resets SET used_at = ? WHERE id = ?").run(Date.now(), reset.id);
// Invalidate all OTHER outstanding resets for this user
db.prepare("UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL").run(Date.now(), user.id);
})();
logAudit({ user_id: user.id, user_email: user.email, action: "auth.reset", target_type: "user", target_id: user.id });
return NextResponse.json({ ok: true });
}
export async function GET(req: Request) {
// Token-validity check (for the /reset/[token] page)
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (!token) return NextResponse.json({ valid: false });
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const reset = getDb().prepare("SELECT expires_at, used_at FROM password_resets WHERE token_hash = ? LIMIT 1").get(tokenHash) as { expires_at: number; used_at: number | null } | undefined;
return NextResponse.json({ valid: !!reset && reset.used_at === null && reset.expires_at > Date.now() });
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getDb, getSetting, setSetting } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
import { sendMail } from "@/lib/mailer";
const KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_secure"];
export async function GET() {
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const out: Record<string, string | null> = {};
for (const k of KEYS) {
out[k] = k === "smtp_password" ? (getSetting(k) ? "***" : null) : getSetting(k);
}
return NextResponse.json(out);
}
const schema = z.object({
smtp_host: z.string().optional(),
smtp_port: z.string().optional(),
smtp_user: z.string().optional(),
smtp_password: z.string().optional(),
smtp_from: z.string().optional(),
smtp_secure: z.string().optional(),
});
export async function PATCH(req: Request) {
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
for (const [k, v] of Object.entries(parsed.data)) {
if (v === undefined) continue;
// Don't overwrite password if "***" (placeholder)
if (k === "smtp_password" && v === "***") continue;
setSetting(k, v);
}
return NextResponse.json({ ok: true });
}
export async function POST(req: Request) {
// Test-Mail
const u = await requireAdmin();
if (u instanceof NextResponse) return u;
const body = await req.json().catch(() => null) as { to?: string } | null;
const to = body?.to || u.email;
const r = await sendMail({
to,
subject: "NexRedirect — SMTP-Test",
html: `<p>Test-Mail von NexRedirect.</p><p>Wenn du das liest, läuft SMTP korrekt.</p>`,
});
// Use db to silence unused-import (no-op)
void getDb;
return NextResponse.json(r);
}

View file

@ -22,7 +22,7 @@ export const authOptions: NextAuthOptions = {
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "E-Mail", type: "email" },
email: { label: "E-Mail oder Benutzername", type: "text" },
password: { label: "Passwort", type: "password" },
},
async authorize(credentials, req) {
@ -37,20 +37,19 @@ export const authOptions: NextAuthOptions = {
return null;
}
const email = credentials.email.toLowerCase().trim();
// Per-email attempt limit: 5 in 15 minutes (slows targeted brute-force)
const emailLimit = checkLimit(`login:email:${email}`, 5, 15 * 60 * 1000);
if (!emailLimit.allowed) {
const identifier = credentials.email.toLowerCase().trim();
const idLimit = checkLimit(`login:id:${identifier}`, 5, 15 * 60 * 1000);
if (!idLimit.allowed) {
await new Promise((r) => setTimeout(r, 1500));
return null;
}
// Match by email OR username
const user = getDb()
.prepare("SELECT id, email, password_hash, role, created_at FROM users WHERE email = ? LIMIT 1")
.get(email) as UserRow | undefined;
.prepare("SELECT id, email, username, password_hash, role, created_at FROM users WHERE email = ? OR username = ? LIMIT 1")
.get(identifier, identifier) as UserRow | undefined;
if (!user) {
// Constant-time delay to prevent user-enumeration timing
await new Promise((r) => setTimeout(r, 200));
return null;
}
@ -60,7 +59,7 @@ export const authOptions: NextAuthOptions = {
return {
id: String(user.id),
email: user.email,
name: user.email,
name: user.username || user.email,
role: user.role,
};
},

View file

@ -236,6 +236,7 @@ export type DomainGroupRow = {
export type UserRow = {
id: number;
email: string;
username: string | null;
password_hash: string;
role: string;
created_at: number;

47
lib/mailer.ts Normal file
View file

@ -0,0 +1,47 @@
import { getSetting } from "./db";
type SmtpConfig = {
host: string;
port: number;
user: string;
password: string;
from: string;
secure: boolean;
};
export function getSmtpConfig(): SmtpConfig | null {
const host = getSetting("smtp_host");
if (!host) return null;
return {
host,
port: Number(getSetting("smtp_port") || 587),
user: getSetting("smtp_user") || "",
password: getSetting("smtp_password") || "",
from: getSetting("smtp_from") || getSetting("smtp_user") || "",
secure: getSetting("smtp_secure") === "true",
};
}
export async function sendMail(opts: { to: string; subject: string; html: string; text?: string }): Promise<{ ok: boolean; error?: string }> {
const cfg = getSmtpConfig();
if (!cfg) return { ok: false, error: "smtp_not_configured" };
try {
const nodemailer = await import("nodemailer");
const transport = nodemailer.default.createTransport({
host: cfg.host,
port: cfg.port,
secure: cfg.secure || cfg.port === 465,
auth: cfg.user ? { user: cfg.user, pass: cfg.password } : undefined,
});
await transport.sendMail({
from: cfg.from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text || opts.html.replace(/<[^>]+>/g, ""),
});
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}

27
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "corex-nexredirect",
"version": "0.1.13",
"version": "0.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "corex-nexredirect",
"version": "0.1.13",
"version": "0.1.25",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
@ -14,6 +14,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@types/nodemailer": "^8.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.5.0",
"class-variance-authority": "^0.7.1",
@ -22,6 +23,7 @@
"maxmind": "^4.3.20",
"next": "^15.5.15",
"next-auth": "^4.24.14",
"nodemailer": "^7.0.13",
"puppeteer-core": "^24.42.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -2115,12 +2117,20 @@
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -3807,6 +3817,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -5128,7 +5148,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.25",
"version": "0.1.26",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",
@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@types/nodemailer": "^8.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.5.0",
"class-variance-authority": "^0.7.1",
@ -26,6 +27,7 @@
"maxmind": "^4.3.20",
"next": "^15.5.15",
"next-auth": "^4.24.14",
"nodemailer": "^7.0.13",
"puppeteer-core": "^24.42.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View file

@ -81,17 +81,33 @@ chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$DATA_DIR"
echo "==> Dependencies installieren"
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-fund"
echo "==> Prebuilt .next/ versuchen"
echo "==> Prebuilt .next/ versuchen (SHA256-verifiziert)"
PREBUILT_OK=0
if [[ -n "$TARGET_TAG" ]]; then
ASSET_URL="https://github.com/${REPO}/releases/download/${TARGET_TAG}/nexredirect-next-${TARGET_TAG}.tar.gz"
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TARGET_TAG}/nexredirect-checksums-${TARGET_TAG}.txt"
if curl -fsSL -o /tmp/next-build.tgz "$ASSET_URL" 2>/dev/null; then
sudo -u "$SERVICE_USER" -H tar -xzf /tmp/next-build.tgz -C "$INSTALL_DIR"
VERIFIED=0
if curl -fsSL -o /tmp/next-checksums.txt "$CHECKSUM_URL" 2>/dev/null; then
EXPECTED=$(awk '{print $1}' /tmp/next-checksums.txt | head -n1)
ACTUAL=$(sha256sum /tmp/next-build.tgz | awk '{print $1}')
if [[ -n "$EXPECTED" && "$EXPECTED" == "$ACTUAL" ]]; then
VERIFIED=1
echo " SHA256 verifiziert."
else
echo " ⚠ SHA256-Mismatch — verwerfe Prebuilt."
fi
rm -f /tmp/next-checksums.txt
fi
if [[ $VERIFIED -eq 1 ]]; then
sudo -u "$SERVICE_USER" -H tar -xzf /tmp/next-build.tgz -C "$INSTALL_DIR"
PREBUILT_OK=1
echo " Prebuilt aus Release übernommen — Build übersprungen."
fi
rm -f /tmp/next-build.tgz
PREBUILT_OK=1
echo " Prebuilt aus Release übernommen — Build übersprungen."
else
echo " Kein Prebuilt für $TARGET_TAG — baue lokal."
fi
if [[ $PREBUILT_OK -eq 0 ]]; then
echo " Kein verifiziertes Prebuilt für $TARGET_TAG — baue lokal."
fi
fi
if [[ $PREBUILT_OK -eq 0 ]]; then