Add Turnstile protection to user actions

This commit is contained in:
2026-09-18 00:39:14 +02:00
parent 79cd7f7475
commit 1970be29e7
25 changed files with 406 additions and 39 deletions
+8
View File
@@ -39,6 +39,14 @@ CRON_SECRET=replace-with-a-long-random-secret
# links inside emails. Set this to the real domain in production.
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Cloudflare Turnstile. The site key is public and is passed to the client
# from the server; the secret is used only by the server-side Siteverify call.
TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO
TURNSTILE_SECRET_KEY=
# Local development value. Production must contain only the public hostname:
# TURNSTILE_HOSTNAMES=anouma.org
TURNSTILE_HOSTNAMES=localhost,127.0.0.1
# Port the app container publishes on the host (behind a reverse proxy this
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
APP_PORT=3000
+3
View File
@@ -190,6 +190,9 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
| `SMTP_*` | The existing ANOUMA mail system |
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
+2 -1
View File
@@ -22,6 +22,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
type Args = { params: Promise<{ slug: string }> };
const WEEKDAY_LABELS: Record<string, string> = { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag" };
@@ -138,7 +139,7 @@ export default async function OfferDetailPage({ params }: Args) {
)}
{offer.groupCapacity && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
{offer.registrationDeadline && <p className="mb-6 text-sm text-anouma-plum/75">Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}</p>}
<GroupRegistrationForm offerSlug={offer.slug!} />
<GroupRegistrationForm offerSlug={offer.slug!} siteKey={turnstileSiteKey} />
</div>
</Section>
) : offer.bookable ? (
+2 -1
View File
@@ -14,6 +14,7 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export async function generateMetadata(): Promise<Metadata> {
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
@@ -60,7 +61,7 @@ export default async function KontaktPage({ searchParams }: { searchParams: Prom
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<Reveal>
<ContactForm subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
<ContactForm siteKey={turnstileSiteKey} subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
</Reveal>
<Reveal delay={0.1} className="space-y-8">
+2 -1
View File
@@ -8,6 +8,7 @@ import { LoginForm } from "@/components/auth/LoginForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Anmelden",
@@ -24,7 +25,7 @@ export default async function LoginPage() {
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
<LoginForm />
<LoginForm siteKey={turnstileSiteKey} />
</Suspense>
<p className="mt-6 text-center text-sm text-anouma-plum">
Noch kein Konto?{" "}
+2 -1
View File
@@ -7,6 +7,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Konto erstellen",
@@ -28,7 +29,7 @@ export default async function RegisterPage() {
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
<RegisterForm />
<RegisterForm siteKey={turnstileSiteKey} />
<p className="mt-6 text-center text-sm text-anouma-plum">
Schon ein Konto?{" "}
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
+2
View File
@@ -17,6 +17,7 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export async function generateMetadata(): Promise<Metadata> {
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
@@ -122,6 +123,7 @@ export default async function TerminBuchenPage() {
/>
<div className="mt-10">
<ContactForm
siteKey={turnstileSiteKey}
subjectPrefix="Terminanfrage über anouma.org"
type="appointment"
submitLabel="Terminanfrage senden"
@@ -8,6 +8,7 @@ import { combineDateAndTime } from "@/lib/meeting/status";
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
export const metadata: Metadata = {
title: "Meeting beitreten",
@@ -42,7 +43,7 @@ export default async function BeitretenPage({ params }: Args) {
</p>
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
<JoinForm slug={slug} />
<JoinForm slug={slug} siteKey={turnstileSiteKey} />
</div>
</div>
</section>
+2 -1
View File
@@ -25,6 +25,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
type Args = { params: Promise<{ slug: string }> };
@@ -137,7 +138,7 @@ export default async function EventDetailPage({ params }: Args) {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
<div className="mt-4 max-w-sm">
<RegisterForm slug={slug} />
<RegisterForm slug={slug} siteKey={turnstileSiteKey} />
</div>
</div>
)}
@@ -5,6 +5,7 @@ import config from "@payload-config";
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
@@ -14,6 +15,7 @@ type Body = {
message?: unknown;
subject?: unknown;
type?: unknown;
token?: unknown;
};
function clean(value: unknown, max: number): string {
@@ -37,6 +39,9 @@ export async function POST(request: Request) {
if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 });
}
if (!(await verifyTurnstileToken(body.token, "contact", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { REST_POST } from "@payloadcms/next/routes";
import config from "@payload-config";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
const payloadPost = REST_POST(config);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.clone().json();
} catch {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const token = isRecord(body) ? body.token : undefined;
if (!(await verifyTurnstileToken(token, "login", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const sanitizedBody = isRecord(body) ? { ...body } : body;
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
const headers = new Headers(request.headers);
headers.delete("content-length");
headers.set("content-type", "application/json");
const downstreamRequest = new Request(request.url, {
method: request.method,
headers,
body: JSON.stringify(sanitizedBody),
});
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers", "login"] }) });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { REST_POST } from "@payloadcms/next/routes";
import config from "@payload-config";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
const payloadPost = REST_POST(config);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.clone().json();
} catch {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const token = isRecord(body) ? body.token : undefined;
if (!(await verifyTurnstileToken(token, "registration", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
const sanitizedBody = isRecord(body) ? { ...body } : body;
if (isRecord(sanitizedBody)) delete sanitizedBody.token;
const headers = new Headers(request.headers);
headers.delete("content-length");
headers.set("content-type", "application/json");
const downstreamRequest = new Request(request.url, {
method: request.method,
headers,
body: JSON.stringify(sanitizedBody),
});
return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers"] }) });
}
@@ -4,6 +4,7 @@ import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
@@ -19,6 +20,7 @@ export async function POST(request: Request) {
const phone = value(body.phone, 80);
const message = value(body.message, 3000);
if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 });
if (!(await verifyTurnstileToken(body.token, "group-registration", request))) return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
try {
const payload = await getPayload({ config });
@@ -7,6 +7,7 @@ import { authorizeMeeting } from "@/lib/meeting/access";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
@@ -25,7 +26,7 @@ export async function POST(request: Request, { params }: Args) {
return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 });
}
let body: { name?: unknown; password?: unknown; clientId?: unknown };
let body: { name?: unknown; password?: unknown; clientId?: unknown; token?: unknown };
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
@@ -42,6 +43,9 @@ export async function POST(request: Request, { params }: Args) {
if (password.length > 200) {
return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 });
}
if (!(await verifyTurnstileToken(body.token, "meeting-join", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
try {
const payload = await getPayload({ config });
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { verifyTurnstileToken } from "@/lib/turnstile/server";
export const dynamic = "force-dynamic";
@@ -35,7 +36,7 @@ export async function POST(request: Request, { params }: Args) {
return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
}
let body: { name?: unknown; email?: unknown };
let body: { name?: unknown; email?: unknown; token?: unknown };
try {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
@@ -48,6 +49,9 @@ export async function POST(request: Request, { params }: Args) {
if (!name || name.length > 120 || !email || email.length > 254 || !email.includes("@")) {
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
}
if (!(await verifyTurnstileToken(body.token, "meeting-registration", request))) {
return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 });
}
try {
const payload = await getPayload({ config });
+9 -2
View File
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
type ContactFormProps = {
toEmail?: string;
@@ -12,12 +13,15 @@ type ContactFormProps = {
};
export function ContactForm({
siteKey,
subjectPrefix = "Allgemeine Anfrage",
type = "contact",
submitLabel = "Nachricht senden",
}: ContactFormProps) {
}: ContactFormProps & { siteKey: string }) {
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [submitting, setSubmitting] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -31,7 +35,7 @@ export function ContactForm({
const response = await fetch("/api/contact/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type }),
body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type, token }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
@@ -46,6 +50,7 @@ export function ContactForm({
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
} finally {
setSubmitting(false);
setResetKey((key) => key + 1);
}
}
@@ -54,8 +59,10 @@ export function ContactForm({
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
<TurnstileWidget siteKey={siteKey} action="contact" resetKey={resetKey} onTokenChange={setToken} />
<button
type="submit"
disabled={submitting || !token}
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
>
{submitting ? "Wird gesendet …" : submitLabel}
+8 -3
View File
@@ -2,10 +2,13 @@
import { useState, type FormEvent } from "react";
import { TextField, TextAreaField } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export function GroupRegistrationForm({ offerSlug }: { offerSlug: string }) {
export function GroupRegistrationForm({ offerSlug, siteKey }: { offerSlug: string; siteKey: string }) {
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
const [busy, setBusy] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -22,13 +25,14 @@ export function GroupRegistrationForm({ offerSlug }: { offerSlug: string }) {
email: data.get("email"),
phone: data.get("phone"),
message: data.get("message"),
token,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
else { setState({ sent: true }); event.currentTarget.reset(); }
} catch { setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); }
finally { setBusy(false); }
finally { setBusy(false); setResetKey((key) => key + 1); }
}
if (state.sent) return <p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-olive" role="status">Danke, deine Anmeldung ist angekommen. Anna meldet sich, sobald sie die Plätze geprüft hat.</p>;
@@ -39,8 +43,9 @@ export function GroupRegistrationForm({ offerSlug }: { offerSlug: string }) {
<TextField label="E-Mail" id="group-email" name="email" type="email" required autoComplete="email" />
<TextField label="Telefon (optional)" id="group-phone" name="phone" type="tel" autoComplete="tel" />
<TextAreaField label="Nachricht / Motivation (optional)" id="group-message" name="message" rows={4} />
<TurnstileWidget siteKey={siteKey} action="group-registration" resetKey={resetKey} onTokenChange={setToken} />
{state.error && <p className="text-sm text-red-700" role="alert">{state.error}</p>}
<button type="submit" disabled={busy} className="rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum disabled:opacity-60">{busy ? "Wird gesendet …" : "Anmeldung senden"}</button>
<button type="submit" disabled={busy || !token} className="rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum disabled:opacity-60">{busy ? "Wird gesendet …" : "Anmeldung senden"}</button>
</form>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
type TurnstileWidgetId = string;
type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
action: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
},
) => TurnstileWidgetId;
reset: (widgetId: TurnstileWidgetId) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
type TurnstileWidgetProps = {
siteKey: string;
action: string;
resetKey: number;
onTokenChange: (token: string | null) => void;
};
export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: TurnstileWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<TurnstileWidgetId | null>(null);
const onTokenChangeRef = useRef(onTokenChange);
const [loadError, setLoadError] = useState(false);
const [verificationStatus, setVerificationStatus] = useState<"idle" | "ready" | "expired">("idle");
useEffect(() => {
onTokenChangeRef.current = onTokenChange;
}, [onTokenChange]);
useEffect(() => {
if (resetKey === 0) return;
if (widgetIdRef.current !== null && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
}
onTokenChangeRef.current(null);
}, [resetKey]);
const render = useCallback(() => {
if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return;
if (!window.turnstile) {
setLoadError(true);
return;
}
try {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
callback: (token) => {
setVerificationStatus("ready");
onTokenChangeRef.current(token);
},
"expired-callback": () => {
setVerificationStatus("expired");
onTokenChangeRef.current(null);
},
"error-callback": () => {
onTokenChangeRef.current(null);
setLoadError(true);
},
});
} catch {
setLoadError(true);
onTokenChangeRef.current(null);
}
}, [action, siteKey]);
return (
<div className="space-y-2" aria-live="polite">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="afterInteractive"
onReady={render}
onError={() => setLoadError(true)}
/>
<div ref={containerRef} />
{!siteKey || loadError ? (
<p className="text-sm text-red-700" role="alert">
Die Sicherheitsprüfung ist momentan nicht verfügbar. Bitte lade die Seite neu und versuche es erneut.
</p>
) : verificationStatus === "expired" ? (
<p className="text-sm text-red-700" role="alert">
Die Sicherheitsprüfung ist abgelaufen. Bitte bestätige sie erneut.
</p>
) : null}
</div>
);
}
+14 -4
View File
@@ -5,12 +5,15 @@ import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
import { TextField, FormError } from "@/components/FormField";
import { safeLoginRedirect } from "@/lib/auth/redirect";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export function LoginForm() {
export function LoginForm({ siteKey }: { siteKey: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -26,10 +29,11 @@ export function LoginForm() {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, token }),
});
if (!res.ok) {
setError("E-Mail oder Passwort ist nicht korrekt.");
const body = await res.json().catch(() => null);
setError(body?.error || "E-Mail oder Passwort ist nicht korrekt.");
setLoading(false);
return;
}
@@ -38,6 +42,8 @@ export function LoginForm() {
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
} finally {
setResetKey((key) => key + 1);
}
}
@@ -45,10 +51,14 @@ export function LoginForm() {
<form onSubmit={handleSubmit} className="space-y-5">
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField label="Passwort" id="password" name="password" type="password" required autoComplete="current-password" />
{searchParams.get("registered") === "1" && (
<p className="text-sm text-anouma-olive" role="status">Dein Konto wurde erstellt. Melde dich jetzt an.</p>
)}
<TurnstileWidget siteKey={siteKey} action="login" resetKey={resetKey} onTokenChange={setToken} />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
disabled={loading || !token}
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
>
{loading ? "Wird geprüft …" : "Anmelden"}
+12 -16
View File
@@ -3,11 +3,14 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { TextField, FormError } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export function RegisterForm() {
export function RegisterForm({ siteKey }: { siteKey: string }) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -23,30 +26,22 @@ export function RegisterForm() {
const createRes = await fetch("/api/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
body: JSON.stringify({ name, email, password, token }),
});
if (!createRes.ok) {
const body = await createRes.json().catch(() => null);
setError(body?.errors?.[0]?.message || "Die Registrierung ist fehlgeschlagen. Ist die E-Mail-Adresse schon vergeben?");
setError(body?.error || body?.errors?.[0]?.message || "Die Registrierung ist fehlgeschlagen. Ist die E-Mail-Adresse schon vergeben?");
setLoading(false);
return;
}
const loginRes = await fetch("/api/customers/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!loginRes.ok) {
router.push("/login");
return;
}
router.push("/konto");
router.refresh();
// Login is separately protected by its own single-use Turnstile token.
router.push("/login?registered=1");
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
} finally {
setResetKey((key) => key + 1);
}
}
@@ -63,10 +58,11 @@ export function RegisterForm() {
minLength={8}
autoComplete="new-password"
/>
<TurnstileWidget siteKey={siteKey} action="registration" resetKey={resetKey} onTokenChange={setToken} />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
disabled={loading || !token}
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
>
{loading ? "Wird erstellt …" : "Konto erstellen"}
+9 -3
View File
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { fieldClass, TextField, FormError } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export const MEETING_SESSION_KEY = "anouma-meeting-session";
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
@@ -37,13 +38,15 @@ export type StoredMeetingSession = {
eventSlug: string;
};
export function JoinForm({ slug }: { slug: string }) {
export function JoinForm({ slug, siteKey }: { slug: string; siteKey: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -53,7 +56,7 @@ export function JoinForm({ slug }: { slug: string }) {
const res = await fetch(`/api/meetings/${slug}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
body: JSON.stringify({ name, password, clientId: getOrCreateClientId(), token }),
credentials: "include",
});
const data = await res.json();
@@ -78,6 +81,8 @@ export function JoinForm({ slug }: { slug: string }) {
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
} finally {
setResetKey((key) => key + 1);
}
}
@@ -118,10 +123,11 @@ export function JoinForm({ slug }: { slug: string }) {
autoComplete="off"
/>
</div>
<TurnstileWidget siteKey={siteKey} action="meeting-join" resetKey={resetKey} onTokenChange={setToken} />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
disabled={loading || !token}
className="w-full rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
>
{loading ? "Wird geprüft …" : "Meeting betreten"}
+9 -3
View File
@@ -2,12 +2,15 @@
import { useState, type FormEvent } from "react";
import { TextField, FormError } from "@/components/FormField";
import { TurnstileWidget } from "@/components/TurnstileWidget";
export function RegisterForm({ slug }: { slug: string }) {
export function RegisterForm({ slug, siteKey }: { slug: string; siteKey: string }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -17,7 +20,7 @@ export function RegisterForm({ slug }: { slug: string }) {
const res = await fetch(`/api/meetings/${slug}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
body: JSON.stringify({ name, email, token }),
});
const data = await res.json();
if (!res.ok) {
@@ -29,6 +32,8 @@ export function RegisterForm({ slug }: { slug: string }) {
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setStatus("error");
} finally {
setResetKey((key) => key + 1);
}
}
@@ -60,10 +65,11 @@ export function RegisterForm({ slug }: { slug: string }) {
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
<TurnstileWidget siteKey={siteKey} action="meeting-registration" resetKey={resetKey} onTokenChange={setToken} />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={status === "loading"}
disabled={status === "loading" || !token}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
>
{status === "loading" ? "Wird gesendet …" : "Zum Termin anmelden"}
+53
View File
@@ -0,0 +1,53 @@
import { getClientIp } from "@/lib/auth/rateLimit";
type SiteverifyResult = {
success?: unknown;
action?: unknown;
hostname?: unknown;
};
function expectedHostnames(): Set<string> {
return new Set(
(process.env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim())
.filter(Boolean),
);
}
export async function verifyTurnstileToken(
token: unknown,
expectedAction: string,
request: Request,
): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
const hostnames = expectedHostnames();
if (
typeof secret !== "string" ||
secret.length === 0 ||
typeof token !== "string" ||
token.length === 0 ||
token.length > 2048 ||
hostnames.size === 0
) {
return false;
}
const body = new URLSearchParams({ secret, response: token });
const remoteIp = getClientIp(request);
if (remoteIp !== "unknown") body.set("remoteip", remoteIp);
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: AbortSignal.timeout(10_000),
body,
});
if (!response.ok) return false;
const result = (await response.json()) as SiteverifyResult;
return result.success === true && result.action === expectedAction && typeof result.hostname === "string" && hostnames.has(result.hostname);
} catch {
return false;
}
}
+3
View File
@@ -304,6 +304,9 @@ MEETING_SESSION_SECRET=
ANALYTICS_SALT_SECRET=
CRON_SECRET=
NEXT_PUBLIC_SERVER_URL=
TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO
TURNSTILE_SECRET_KEY=
TURNSTILE_HOSTNAMES=anouma.org
APP_PORT=3000
SMTP_HOST=
SMTP_PORT=587
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifyTurnstileToken } from "../lib/turnstile/server";
const request = new Request("https://anouma.org/api/test", {
headers: { "x-forwarded-for": "203.0.113.10" },
});
test("Turnstile verification fails closed for missing configuration or token", async () => {
const originalFetch = globalThis.fetch;
let called = false;
globalThis.fetch = async () => {
called = true;
throw new Error("fetch must not run");
};
try {
delete process.env.TURNSTILE_SECRET_KEY;
process.env.TURNSTILE_HOSTNAMES = "anouma.org";
assert.equal(await verifyTurnstileToken("token", "contact", request), false);
process.env.TURNSTILE_SECRET_KEY = "test-secret";
assert.equal(await verifyTurnstileToken(undefined, "contact", request), false);
assert.equal(called, false);
} finally {
globalThis.fetch = originalFetch;
delete process.env.TURNSTILE_SECRET_KEY;
delete process.env.TURNSTILE_HOSTNAMES;
}
});
test("Turnstile verification accepts the expected action and hostname", async () => {
const originalFetch = globalThis.fetch;
let receivedBody = "";
globalThis.fetch = async (_input, init) => {
receivedBody = String(init?.body ?? "");
return new Response(JSON.stringify({ success: true, action: "contact", hostname: "anouma.org" }), { status: 200 });
};
try {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
process.env.TURNSTILE_HOSTNAMES = "anouma.org";
assert.equal(await verifyTurnstileToken("fresh-token", "contact", request), true);
const params = new URLSearchParams(receivedBody);
assert.equal(params.get("response"), "fresh-token");
assert.equal(params.get("secret"), "test-secret");
assert.equal(params.get("remoteip"), "203.0.113.10");
} finally {
globalThis.fetch = originalFetch;
delete process.env.TURNSTILE_SECRET_KEY;
delete process.env.TURNSTILE_HOSTNAMES;
}
});
test("Turnstile verification rejects action, hostname, and upstream failures", async () => {
const originalFetch = globalThis.fetch;
process.env.TURNSTILE_SECRET_KEY = "test-secret";
process.env.TURNSTILE_HOSTNAMES = "anouma.org";
try {
globalThis.fetch = async () => new Response(JSON.stringify({ success: true, action: "login", hostname: "anouma.org" }), { status: 200 });
assert.equal(await verifyTurnstileToken("token", "contact", request), false);
globalThis.fetch = async () => new Response(JSON.stringify({ success: true, action: "contact", hostname: "evil.example" }), { status: 200 });
assert.equal(await verifyTurnstileToken("token", "contact", request), false);
globalThis.fetch = async () => new Response("upstream error", { status: 503 });
assert.equal(await verifyTurnstileToken("token", "contact", request), false);
} finally {
globalThis.fetch = originalFetch;
delete process.env.TURNSTILE_SECRET_KEY;
delete process.env.TURNSTILE_HOSTNAMES;
}
});